How-to › Operate integrations in production

How to load AWS secrets without a vendor SDK in every service#

Read one Secrets Manager secret from Node, Go, and Python services through a sekreto chain or the AWS SDKs, and see what each path reaches and what it hides.

Audience
Platform team
Level
intermediate
Topic
Load credentials and secrets
Languages
TypeScript, Python and Go
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
@aws-sdk/client-secrets-managerA Node service that already depends on the AWS SDK, or needs a version stage, a binary secret, or the error textA client and its tree of Smithy packages in the bundle, code that names the store, and a JSON parse at every call siteThe same code has to read a vault or a .env file somewhere else
boto3 get_secret_valueA Python service, where a pinned requirements file is the whole installSeven packages and 36 MB in the image, and code that knows the store, the secret name, and the JSON shapeThe service calls no other AWS API and must also run outside AWS
sekretoA mixed-language fleet that should name secrets and never the store, with the Node port on npm and the Go port on the module proxyA 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 gitOne language, one store, a team that already knows the SDK, or a service whose only credential is a role
SSM Parameter StoreStatic keys that rotate by hand and fit in 4 KB, at no charge for standard parametersNo rotation function, no staging labels, and kms:Decrypt needed on every read of a SecureStringA 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.

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

  1. GetSecretValue says that without a VersionId or a VersionStage it returns the AWSCURRENT version, and that the two, when both are given, must agree. A version carries between one and twenty staging labels, by the reference’s bounds on VersionStages; AWSCURRENT and AWSPREVIOUS are the two the rotation process moves. The chain sends neither parameter, so it reads the default and cannot name another label. ↩︎ Back to text

  2. 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

  3. 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 matches another_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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.