Your billing service is Node, your ledger is Go, and your reports job is Python, and all three read the same payment key from Secrets Manager. Each carries a full AWS SDK to make one signed call. Three bundles, three lockfiles, and three upgrade cycles exist to fetch one string, and each SDK advisory lands on three teams.
What you get
You will end up with Node, Go, and Python services reading one secret by name, two without the AWS SDK, and a test proving the values match. This is for you if each service in a mixed fleet carries the AWS SDK for one key.
Short answer
sekreto’s awssecrets provider reads a Secrets Manager secret by name, billco.key, in Node and
Go with no AWS SDK. Both ports sign with their own SigV4 and return the same bytes; Python keeps
boto3, because that port installs from git rather than PyPI. Use the SDK where the chain cannot
reach: the AWSPREVIOUS stage, a binary secret, or the AWS error text. Grant each service its
own secret, because a policy names secrets, not the keys inside one.
You will need
Node 22, Go 1.24, and Python 3.12 or later, one per service, and an identity allowed to read one
secret. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, Python 3.13.16, @voxgig/sekreto
0.3.0, @aws-sdk/client-secrets-manager 3.1152.0, @aws-sdk/client-ssm 3.1152.0, boto3
1.43.114, and the Go port of sekreto at the pseudo-version in this page’s versions. Secrets
Manager and Parameter Store are one Python stand-in on loopback that speaks their JSON protocol,
checks each SigV4 signature and its scope, and applies an identity policy per principal.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| @aws-sdk/client-secrets-manager | A Node service that already depends on the AWS SDK, or needs a version stage, a binary secret, or the error text | A client and its tree of Smithy packages in the bundle, code that names the store, and a JSON parse at every call site | The same code has to read a vault or a .env file somewhere else |
boto3 get_secret_value | A Python service, where a pinned requirements file is the whole install | Seven packages and 36 MB in the image, and code that knows the store, the secret name, and the JSON shape | The service calls no other AWS API and must also run outside AWS |
| sekreto | A mixed-language fleet that should name secrets and never the store, with the Node port on npm and the Go port on the module proxy | A SigV4 signer of its own to trust, credentials from the spec or the AWS_* variables only, a refusal reported as 400 without the AWS message, AWSCURRENT only, and a Python port installed from git | One language, one store, a team that already knows the SDK, or a service whose only credential is a role |
| SSM Parameter Store | Static keys that rotate by hand and fit in 4 KB, at no charge for standard parameters | No rotation function, no staging labels, and kms:Decrypt needed on every read of a SecureString | A key a rotation function must turn over, or a value over 8 KB |
Voxgig maintains sekreto. This page compares it with @aws-sdk/client-secrets-manager, boto3,
and SSM Parameter Store.
The SDKs hand you the whole API, every stage and version id, binary payloads, and the sentence
IAM wrote when it refused you. The cost is a client that names AWS in every service. sekreto
hands you a name and a value in two languages and hides the rest; when AWS refuses a call, the
provider reports 400 and keeps the reason. Parameter Store charges nothing per standard
parameter and offers no rotation machinery.
Read one name from three services
billco is a Secrets Manager secret whose value is a JSON map with a key field, the AWS idiom
of one map per secret. sekreto names that field
billco.key: the last segment is the field and the rest is the secret’s path, so db.pass.main
reads the secret db/pass. The billing service asks for the name and nothing else.
import { createHash } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { Sekreto } from '@voxgig/sekreto'
import { awssecrets } from '@voxgig/sekreto/plugins/aws'
export const secrets = new Sekreto({
plugins: [awssecrets],
providers: [{ kind: 'awssecrets', region: process.env.AWS_REGION, addr: process.env.SECRETS_ENDPOINT }],
})
export const fingerprint = (value) => createHash('sha256').update(value).digest('hex').slice(0, 16)
// fileURLToPath, because import.meta.url writes a space as %20 and the comparison then fails.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const key = await secrets.get('billco.key')
console.log(`billing node sekreto-awssecrets ${fingerprint(key)}`)
}
addr exists for the stand-in. Unset, the provider builds the regional endpoint; set to plain
http anywhere but loopback, it refuses to send a signed request in the clear. Region and
credentials come from the spec first and the standard AWS_* variables second, which Lambda sets
from a function’s execution role.
An ECS task role sets an endpoint for the
SDKs instead,
which neither port reads. The signer is SigV4 in the provider’s own code, so the bundle
carries no AWS SDK.
The ledger does the same in Go through the
Go port, with aws.Secrets as the plugin and
a ProviderSpec of kind awssecrets. The reports job keeps boto3.
client = boto3.client(
'secretsmanager',
region_name=os.environ['AWS_REGION'],
endpoint_url=os.environ.get('SECRETS_ENDPOINT'),
)
response = client.get_secret_value(SecretId='billco')
key = json.loads(response['SecretString'])['key']
print(f"reports python boto3-get_secret_value {hashlib.sha256(key.encode()).hexdigest()[:16]}")
node fleet.mjs
service language client sha256 of billco.key, first 16
billing node sekreto-awssecrets 822547166b7cf954
ledger go sekreto-awssecrets 822547166b7cf954
reports python boto3-get_secret_value 822547166b7cf954
one value, read three ways
what the stand-in recorded, as CloudTrail would
billing secretsmanager:GetSecretValue allowed arn:aws:secretsmanager:us-east-1:111122223333:secret:billco-AbCdEf
ledger secretsmanager:GetSecretValue allowed arn:aws:secretsmanager:us-east-1:111122223333:secret:billco-AbCdEf
reports secretsmanager:GetSecretValue allowed arn:aws:secretsmanager:us-east-1:111122223333:secret:billco-AbCdEf
One fingerprint from three signers means the stand-in accepted all three signatures, each
recomputed from the key behind the access key id. The record at the bottom is what CloudTrail
keeps for
GetSecretValue:
the principal, the action, and the ARN, never the value.
Keep the SDK for the version stages
A rotation shows what the chain hides. GetSecretValue with no stage named returns the version
labeled AWSCURRENT; after a rotation the previous version carries AWSPREVIOUS, and the SDK
asks for it by name.1
// The SDK call: the whole response, with the stage and version id the chain never shows.
async function sdk(stage) {
const res = await sm.send(new GetSecretValueCommand({ SecretId: 'billco', ...(stage ? { VersionStage: stage } : {}) }))
return `${res.VersionStages.join(',').padEnd(12)}${res.VersionId.slice(0, 8)} key ${mask(JSON.parse(res.SecretString).key)}`
}
node stages.mjs
before the rotation
SDK GetSecretValue, no stage named AWSCURRENT EXAMPLE1 key sk_live...llc0
sekreto get('billco.key') sk_live...llc0
rotate: a new version takes AWSCURRENT, the old one keeps AWSPREVIOUS
SDK GetSecretValue, VersionStage AWSCURRENT AWSCURRENT EXAMPLE2 key sk_live...llc1
SDK GetSecretValue, VersionStage AWSPREVIOUS AWSPREVIOUS EXAMPLE1 key sk_live...llc0
sekreto get('billco.key'), from its cache sk_live...llc0
sekreto refresh(), then get('billco.key') sk_live...llc1
sekreto, the AWSPREVIOUS value no call reaches it: the provider sends SecretId alone
Parameter Store: /billco/webhook is a SecureString
SDK GetParameter, WithDecryption true SecureString version 1 whsec_E...lco1
SDK GetParameter, WithDecryption unset SecureString version 1 AQICAHg...xt==
sekreto getfrom('awsparams', 'billco.webhook') whsec_E...lco1 (reads /billco/webhook, always decrypted)
sekreto get('billco.webhook') whsec_E...lco1 (the chain answers from Secrets Manager first)
The SDK sees both stages and both version ids. The chain sends
SecretId alone, so it only ever sees AWSCURRENT, and it keeps what it resolved until
refresh(), because a Sekreto instance caches by store and name for its whole life.
Parameter Store is the cheaper home for a key nobody rotates by machine.2 A SecureString
comes back still encrypted unless the call sets
WithDecryption,
which needs kms:Decrypt on the key. The awsparams provider always decrypts and maps
billco.webhook to /billco/webhook. In a chain holding both stores, get takes the first hit,
here the Secrets Manager field; getfrom names the store.
Grant each service its own secret
An identity policy names the secret’s ARN and the action. A secret under a customer managed key,
rather than the AWS managed aws/secretsmanager, also needs kms:Decrypt, which the
identity policy examples
show as a second statement.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:billco-??????"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
]
}
The six question marks stand for the six random characters Secrets Manager appends to a secret’s name in its ARN.3
A policy stops at the secret. It cannot name a key inside the secret’s JSON, so a service granted
one JSON blob can read every key in it. CloudTrail then records one allowed GetSecretValue
without naming the key that was used. The reports service needs the sendgrid key and is
granted shared:
node pitfall.mjs
one secret, every key: `shared` holds stripe, sendgrid and twilio
the reports service needs sendgrid; its policy allows GetSecretValue on shared
SDK GetSecretValue shared, keys returned stripe, sendgrid, twilio
sekreto getfrom('awssecrets', 'shared.sendgrid') SG.EXAM...MPLE
sekreto getfrom('awssecrets', 'shared.stripe') sk_live...r1pe
sekreto getfrom('awssecrets', 'shared.twilio') EXAMPLE...en00
CloudTrail records GetSecretValue on shared, allowed, and nothing about which key was read
one secret per key, and a policy that names shared/sendgrid alone
sekreto getfrom('awssecrets', 'shared.sendgrid.value') SG.EXAM...MPLE
sekreto getfrom('awssecrets', 'shared.stripe.value') SekretoError: sekreto: aws secretsmanager error: 400
SDK GetSecretValue shared/stripe AccessDeniedException: User: arn:aws:iam::111122223333:user/reports is not authorized to perform: secretsmanager:GetSecretValue on resource: arn:aws:secretsmanager:us-east-1:111122223333:secret:shared/stripe-AbCdEf because no identity-based policy allows the secretsmanager:GetSecretValue action
Three secrets instead of one gives the policy a name to allow, at $0.40 a month each in Secrets
Manager, or at no charge as standard parameters.2 shared.sendgrid.value reads the
plain-string secret shared/sendgrid, because a single-segment path takes the conventional field
value. The refused read is where the two clients differ most: the SDK throws
AccessDeniedException with the sentence IAM wrote, and the provider throws
sekreto: aws secretsmanager error: 400 and drops the body.
Check it worked
Eight tests cover the three services agreeing, bad signatures, the stages after a rotation, the
SecureString, the blob against the split, the chain’s two failures, and a path with spaces:
test('a denied secret is an error that stops the chain; a missing field is a miss that continues it', async () => {
const secrets = chain('reports', { kind: 'memory', values: { SHARED_STRIPE_VALUE: 'from-memory', BILLCO_RATE_LIMIT: '600' } })
// AccessDenied stops the walk: falling through would hand the caller a weaker store's answer.
await assert.rejects(secrets.get('shared.stripe.value'), SekretoError)
// The secret is allowed and read, and its JSON has no rate_limit field: a miss, so the chain
// moves on to the next store.
assert.equal(await secrets.get('billco.rate_limit'), '600')
const { calls } = await standin.admin({ op: 'calls' })
const last = calls.at(-1)
assert.deepEqual([last.principal, last.result, last.resource.endsWith('billco-AbCdEf')], ['reports', 'allowed', true])
})
node --test fleet.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
A secret outside the policy is never a miss, because IAM refuses before Secrets Manager looks,
so the chain stops with a SekretoError rather than ask a weaker store. A field absent from an
allowed secret is a miss, and the next store answers.
When it goes wrong
Every call fails with sekreto: aws secretsmanager error: 400 and no reason. The provider keeps
the status code and drops the __type and the message, so a wrong name, a denied policy, and a
missing kms:Decrypt read the same. Run the one call through the SDK, or read the
CloudTrail entry, which carries the AccessDeniedException text.
node errors.mjs
no region in the spec or the environment
SekretoError: sekreto: aws: no region (set region or AWS_REGION)
an ECS task role, whose credentials are behind an endpoint
SekretoError: sekreto: aws: no credentials (set keyid/secret or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY)
a plain http address that is not loopback
SekretoError: sekreto: refusing to send a token in plaintext to http://secrets.internal.example:8080 (use https)
the wrong signing key, through sekreto
SekretoError: sekreto: aws secretsmanager error: 403
the wrong signing key, through the SDK
InvalidSignatureException: The request signature we calculated does not match the signature you provided.
the right key, a loopback address, and a region
read 28 characters
The service dies at startup with sekreto: aws: no region on a laptop, or
sekreto: aws: no credentials under an ECS task role. Region and credentials come from the spec or
the AWS_* variables, and a laptop has neither. Set AWS_REGION and a profile’s keys,
or put a dotenv provider ahead of awssecrets for local work.
The old key is still served after a rotation. Every instance holds what it resolved. Call
refresh() on a timer, or on the first 401 from the provider the key is for,
so a rotation lands within one interval.
When not to do this
Do not add sekreto to a fleet of one language that already depends on the AWS SDK. The SDK is in the bundle anyway, reaches every stage and binary payload, and its errors arrive with the sentence IAM wrote. The chain would add a signer to audit and take those away.
Do not move a service whose only credential is a role onto the providers. Neither port reads the endpoint an ECS task role or instance profile serves.
Do not read a secret that a rotation function manages through the chain alone. A rotation moves
AWSPENDING and AWSPREVIOUS between versions, and testing it means asking for a stage by name,
which only the SDK can do.
Do not keep every key in one JSON secret to save $0.40 a month per key. The policy you write for it grants all of them to whoever needs one, and CloudTrail cannot tell you which was read. Static keys that fit in 4 KB belong in standard parameters, where splitting costs nothing.
Do not pin the Python port of sekreto from git in a production image. Its pyproject.toml
declares its dependency from a git URL, which no lockfile verifies and no registry mirrors. boto3
is the Python client to pin until a release of that port reaches PyPI.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, Python 3.13.16, @voxgig/sekreto 0.3.0,
@aws-sdk/client-secrets-manager 3.1152.0, @aws-sdk/client-ssm 3.1152.0, boto3 1.43.114, and
github.com/voxgig/sekreto/go at v0.0.0-20261015215509-ee837130bf44. Every output block is
what the command preceding it printed. Secrets Manager and Parameter Store were a Python stand-in
on 127.0.0.1 that verifies SigV4 signatures and applies an allow list per principal. No AWS
account, secret, or KMS key was used. The prices are read from the AWS pricing pages.
Footnotes
-
GetSecretValuesays that without aVersionIdor aVersionStageit returns theAWSCURRENTversion, and that the two, when both are given, must agree. A version carries between one and twenty staging labels, by the reference’s bounds onVersionStages;AWSCURRENTandAWSPREVIOUSare the two the rotation process moves. The chain sends neither parameter, so it reads the default and cannot name another label. ↩︎ Back to text -
The Secrets Manager pricing page charges $0.40 per secret per month and $0.05 per 10,000 API calls, and adds that creating new versions is free. The Parameter Store page makes standard parameters “available at no additional charge” and charges $0.05 per advanced parameter per month, prorated hourly. The tier table explains the charge as 8 KB instead of 4 KB, parameter policies, and 100,000 parameters instead of 10,000. Forty static keys cost $192 a year as secrets and $0 as standard parameters. ↩︎ Back to text ↩︎ 2
-
Secrets Manager appends six random characters to a secret’s name to make its ARN, and the identity policy examples spend a paragraph on matching them. A trailing
-*also matchesanother_secret_name-<anything-here>a1b2c3, so the recommended wildcard is six question marks, one per character nobody can predict. A secret deleted and recreated under the same name matches the same six question marks. The principal then “automatically receives permission to the new secret, even though the 6 characters changed,” as the page puts it. The suffix tells two secrets of one name apart, which is the property a policy written this way gives up. ↩︎ Back to text