Skip to content

Scoping a Secrets Manager Read Policy to One Secret

11 min read · updated August 11, 2026

Almost every Secrets Manager policy in the wild ends in either "Resource": "*" or a trailing asterisk after the secret name. The first grants every secret in the account; the second is subtly wider than it looks, for a reason that is documented and rarely read.

The suffix that breaks the obvious policy

Secrets Manager appends six random characters to the secret name when it builds the ARN. A secret you created as prod/providers/openai has an ARN ending prod/providers/openai-a1b2c3, and you do not know those six characters until the secret exists. That is inconvenient in infrastructure code, which wants to write the policy and the secret in the same apply, so people reach for a wildcard.

AWS is explicit about why the obvious wildcard is wrong. Its documentation states that with the syntax another_secret_name-*, Secrets Manager matches not only the intended secret with the six random characters but also another_secret_name-<anything-here>a1b2c3 — and recommends the ?????? form, where each question mark matches one character (AWS, identity-based policies for Secrets Manager).

The gap is real and easy to exploit by accident rather than by malice. A policy for prod/providers/openai-* also grants prod/providers/openai-admin-x9y8z7 and prod/providers/openai-billing-q1w2e3. In an account where secrets are named by prefix — which is the naming convention everyone adopts — a trailing asterisk quietly grants the whole prefix tree. Six question marks grant exactly one secret name.

AWS also notes the trade-off: because you can predict every part of the ARN except those six characters, the ?????? form lets you grant access to a secret that does not exist yet — and if you delete and recreate a secret under the same name, the identity keeps access even though the random characters changed.

There is a second thing the ARN does not carry, and it changes what a resource string can express. A secret ARN identifies the secret, not a version of it, so it does not lengthen when rotation creates new versions and there is no per-version ARN to name in a policy. Every version a secret has ever held — the current one, the pending one mid-rotation, and the previous one retained as AWSPREVIOUS — is reachable by anyone whose policy matches that single ARN, because GetSecretValue takes VersionId or VersionStage as a request parameter rather than as part of the resource. If you want a reader confined to the live value, that is a condition key and not a resource string; see the conditions section below.

This is where a trailing asterisk quietly compounds. A policy written as prod/providers/* was probably intended to mean “the provider keys we have today”, and it does not: it means every secret ever created under that prefix, including the ones a future team adds, including whatever a rotation function stores there, and including every historical version of all of them. The set it grants grows without anyone editing the policy, which is the property that makes it invisible in review. Six question marks pin the grant to one name, and the grant stops growing.

The policy

Two actions cover reading. GetSecretValue returns the value; DescribeSecret returns the metadata, and most SDK caching layers and the Lambda extension call it to check version staging. Grant both or expect an access-denied error from a call you did not know you were making.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOneProviderKey",
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/providers/openai-??????"
    }
  ]
}

In Terraform, prefer referencing the created secret’s ARN directly when the secret is in the same configuration, because then there is no wildcard at all and no ambiguity:

data "aws_iam_policy_document" "read_provider_key" {
  statement {
    sid    = "ReadOneProviderKey"
    effect = "Allow"
    actions = [
      "secretsmanager:GetSecretValue",
      "secretsmanager:DescribeSecret",
    ]
    resources = [aws_secretsmanager_secret.openai.arn]
  }
}

Use the ?????? form when the policy and the secret are managed separately — a platform team owning roles and an application team owning secrets is the usual reason — and prefer the direct reference whenever you can have it.

The KMS statement people forget

A secret encrypted with a customer-managed KMS key needs a second statement. The Secrets Manager permission lets you call the API; the KMS permission lets the service decrypt on your behalf. Without it the call fails with an access-denied error that names KMS, which is easy to read as a Secrets Manager problem. AWS’s own example pairs the two statements exactly this way:

{
  "Sid": "DecryptWithTheSecretsKey",
  "Effect": "Allow",
  "Action": "kms:Decrypt",
  "Resource": "arn:aws:kms:eu-west-1:123456789012:key/1a2b3c4d-5e6f-7081-9234-abcdef012345",
  "Condition": {
    "StringEquals": {
      "kms:ViaService": "secretsmanager.eu-west-1.amazonaws.com"
    }
  }
}

The kms:ViaService condition is the part worth adding beyond the documented minimum: it means the identity can use the key only through Secrets Manager, not to decrypt arbitrary ciphertext elsewhere. A secret encrypted with the AWS managed key aws/secretsmanager needs no KMS statement at all, which is why this problem only appears when somebody hardens the encryption.

Narrowing further with conditions

Resource matching is one axis. Three conditions are worth knowing because they close holes an ARN cannot.

  • Tag-based access. secretsmanager:ResourceTag/Environment lets one policy cover a set of secrets defined by tag rather than by name, which survives renaming. It is only as good as your tagging discipline: an untagged new secret is simply not covered, which fails closed and is the right direction.
  • Version staging. secretsmanager:VersionStage can restrict a reader to AWSCURRENT, so an application cannot fetch the AWSPENDING value mid-rotation. Useful when a rotation function and its consumers share a role, which they should not, but sometimes do.
  • Network path. aws:SourceVpce restricts the call to a specific interface VPC endpoint, so a leaked role credential is not usable from outside your network. This pairs with a resource policy on the secret itself, which is the only way to deny access an identity policy in another account might grant.

Resist adding all three at once. Each condition is another way for a legitimate call to fail with an unhelpful message, and an over-conditioned policy tends to get replaced wholesale with "Resource": "*" during the next incident.

Not allowed is not the same as denied

A narrow identity policy makes an identity unable to read the other secrets through that policy. It does not stop another policy from granting them. IAM evaluation is a union of every applicable allow, overridden only by an explicit deny, so the scoped policy you just wrote raises the floor and does nothing to the ceiling. Attach it alongside a role that also carries SecretsManagerReadWrite and the effective permission is the managed policy’s.

Three mechanisms produce a real ceiling, and they are the ones to reach for when the requirement is “this role must never read anything else” rather than “this role is allowed to read one thing”.

  • A permissions boundary on the role. The role’s effective permissions become the intersection of its policies and the boundary, so a boundary listing one secret ARN caps the role no matter what a future policy attachment adds. This is the right tool when application teams can attach their own policies.
  • An explicit deny with a NotResource element. A statement denying secretsmanager:GetSecretValue on every resource except the one ARN overrides all allows, including ones you have not seen. Powerful and sharp-edged: an explicit deny is unconditional, so a legitimate future need requires editing this statement rather than adding a policy.
  • A resource policy on the secret. This is the only control that lives with the secret rather than with the caller, which makes it the one that survives a role being recreated by a different team. For cross-account access it is not optional at all: a caller in another account needs both an identity policy in its own account and a resource policy on the secret, and if the secret uses a customer-managed key, a key policy granting kms:Decrypt to that principal as well. Missing any of the three produces the same access-denied message, which is why cross-account secret access is usually debugged in the wrong place.

Service control policies sit above all of this at the organisation level and can only remove permissions, never add them — useful for a blanket “no secret reads from outside our VPC endpoints”, useless for expressing that one role may read one secret. Knowing which layer a requirement belongs in is most of the work; writing the JSON is the easy part.

Proving it is actually narrow

Test both directions. The IAM policy simulator answers from the policy document, and a real call answers from the whole evaluation chain including resource policies, SCPs and permission boundaries — use both.

# Should be allowed.
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/inference-worker \
  --action-names secretsmanager:GetSecretValue \
  --resource-arns arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/providers/openai-a1b2c3 \
  --query 'EvaluationResults[].EvalDecision'

# Should be denied — the neighbouring secret a trailing asterisk would have granted.
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/inference-worker \
  --action-names secretsmanager:GetSecretValue \
  --resource-arns arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/providers/openai-admin-x9y8z7 \
  --query 'EvaluationResults[].EvalDecision'

The second command is the whole point of the page: if it returns allowed, your resource string still has a trailing asterisk somewhere. Add it to whatever runs after an IAM change, alongside the rotation checks from automating key rotation with a Secrets Manager trigger.