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

Source: https://voxgig.com/howto/aws-secrets-manager-without-a-vendor-sdk

- Audience: platform-team
- Level: intermediate
- Languages: typescript, python, go
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/secrets-manager/command/GetSecretValueCommand/) | 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`](https://docs.aws.amazon.com/boto3/latest/reference/services/secretsmanager/client/get_secret_value.html) | 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](https://github.com/voxgig/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](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) | 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.

```js title="billing.mjs"
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](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html).
An ECS task role sets an endpoint for the
[SDKs](https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html) 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](https://pkg.go.dev/github.com/voxgig/sekreto/go), with `aws.Secrets` as the plugin and
a `ProviderSpec` of kind `awssecrets`. The reports job keeps boto3.

```py title="reports/reports.py"
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]}")
```

```bash
node fleet.mjs
```

```text output
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`](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html):
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]

```js title="stages.mjs"
// 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)}`
}
```

```bash
node stages.mjs
```

```text output
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`](https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_GetParameter.html),
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](https://docs.aws.amazon.com/secretsmanager/latest/userguide/auth-and-access_iam-policies.html)
show as a second statement.

```json title="policy.json"
{
  "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`:

```bash
node pitfall.mjs
```

```text output
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:

```js title="fleet.test.mjs"
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])
})
```

```bash
node --test fleet.test.mjs
```

```text output
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.

```bash
node errors.mjs
```

```text output
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

- [Load secrets in Lambda without a fetch per invocation](/howto/cache-lambda-secrets-across-invocations)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

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

[^1]: [`GetSecretValue`](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html)
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.

[^2]: The [Secrets Manager pricing page](https://aws.amazon.com/secrets-manager/pricing/) 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](https://aws.amazon.com/systems-manager/pricing/) makes standard
parameters "available at no additional charge" and charges $0.05 per advanced parameter per
month, prorated hourly. The
[tier table](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-advanced-parameters.html)
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.

[^3]: Secrets Manager appends six random characters to a secret's name to make its ARN, and the
[identity policy examples](https://docs.aws.amazon.com/secretsmanager/latest/userguide/auth-and-access_iam-policies.html)
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.