Best Practices

AWS SSM Parameter Store Security

Parameter Store is everywhere in AWS workloads, which means it accumulates secrets, configuration, and bad IAM over time. Here is the security review I run on every Parameter Store deployment.

Nayan Dey
Senior Security Engineer
7 min read

SSM Parameter Store is the quiet default for configuration and secret storage in AWS. It is cheaper than Secrets Manager, simpler than AppConfig, and shows up in almost every AWS account I review. It is also the service where I find the most exposed credentials, the loosest IAM policies, and the oldest misconfigurations. This post is the checklist I run through on every Parameter Store engagement, in the order I run it.

Inventory everything first

The first task is always to list every parameter in every account. aws ssm describe-parameters --max-results 50 paginated across every region, with --parameter-filters applied to separate String, StringList, and SecureString types.

The distribution you want is: almost everything is SecureString, with a small number of String entries for non-sensitive configuration like feature toggles or region names. The distribution you usually find is: mostly String with a long tail of parameters whose names include secret, password, key, token, or credential. Those are the first thing to investigate. A parameter named api-secret-key stored as String is plaintext in CloudFormation, plaintext in CloudTrail, and plaintext to anyone with ssm:GetParameter.

Command to find the obvious ones quickly:

aws ssm describe-parameters --parameter-filters "Key=Name,Option=Contains,Values=secret" "Key=Type,Values=String" --region us-east-1

Run this across every region, collate, and convert the matches to SecureString. The conversion is not free (there is no UpdateParameter API that changes type in place; you delete and recreate), but it is a one-time operation and it eliminates the highest-risk parameters immediately.

SecureString and KMS key scoping

SecureString parameters are encrypted with a KMS key. By default, they use the alias/aws/ssm AWS-managed key. This key has a policy that grants the service access on your behalf, and any principal in your account with ssm:GetParameter permission for the parameter can decrypt it, because the decrypt happens implicitly when you fetch the parameter.

The problem with the default key is that the KMS decrypt trail is indistinguishable from normal SSM usage. Every time any Lambda, any EC2 instance, any user fetches any SecureString parameter, it shows up as a kms:Decrypt event against alias/aws/ssm. You cannot distinguish between legitimate application reads and an attacker exfiltrating secrets.

The fix: create customer-managed KMS keys, scoped to specific use cases. A ssm-prod-app-secrets key for production application secrets, a ssm-ci-credentials key for CI pipeline secrets, a ssm-operational key for operational configuration. Each key has a policy that grants decrypt only to the specific roles that should read its associated parameters.

The benefit: kms:Decrypt events now tell you exactly what kind of secret was accessed and by whom, and unexpected decrypts against a narrowly scoped key are immediately suspicious. You can alert on kms:Decrypt against ssm-prod-app-secrets from any principal outside the production application role list.

Path-based IAM, properly

Parameter Store parameters are hierarchical: /prod/app1/db-password is a path. IAM policies can grant access on path prefixes using arn:aws:ssm:region:account:parameter/prod/app1/*.

Use this aggressively. Every application has a dedicated path. Every environment has a dedicated path prefix. Every role's parameter access is scoped to the specific paths it owns.

The anti-pattern: ssm:GetParameter on Resource: "*" because "it is just Parameter Store, everyone uses it." This grants every principal with the role the ability to read every parameter in the account, including parameters from unrelated applications. I have seen this grant result in a developer's debugging session inadvertently reading production database credentials from a completely unrelated application because both were in the same account.

The correct pattern: each application's role has ssm:GetParameter and ssm:GetParametersByPath on arn:aws:ssm:region:account:parameter/<app-name>/*, and nothing else. Cross-application reads require explicit grants, which forces the question of why the cross-application read is needed.

For path-based IAM to work with ssm:GetParametersByPath, you also need ssm:DescribeParameters at the account level, which is an account-scoped action and cannot be restricted by resource. This is a known weakness; the workaround is that DescribeParameters only returns parameter names (not values), so the information leak is the existence and names of parameters, not their content. Keep parameter names non-sensitive.

Parameter policies for expiration and rotation

Parameter Store supports parameter policies: expiration, expiration notification, and no-change notification. These are only available for the Advanced tier (ten cents per parameter per month, which is cheap for the amount of discipline they enforce).

Expiration. A parameter can be set to auto-delete after a specific timestamp. Useful for temporary credentials, one-time tokens, and test secrets that should not outlive their intended use. If the parameter is critical, expiration also functions as a forcing function to rotate: if you do not rotate, the parameter is deleted and the workload breaks.

ExpirationNotification. Sends an EventBridge event when the parameter is approaching expiration. Use this to drive rotation automation.

NoChangeNotification. Sends an EventBridge event when a parameter has not been modified in the specified time. Useful for detecting stale credentials that nobody has rotated.

These three policies, combined with an EventBridge rule that routes notifications to a rotation Lambda or to a human review queue, get you most of the way to a rotation-by-default posture for secrets in Parameter Store.

CloudTrail and decryption auditing

Every GetParameter call generates a CloudTrail event. Every kms:Decrypt against the parameter's KMS key generates another CloudTrail event. You get two audit trails for every SecureString read, which is useful because the GetParameter event does not include the parameter value but does include the parameter name; the kms:Decrypt event does not include the parameter name but includes the encryption context from which you can derive it.

Together, they let you answer: which principal read which parameter at what time? Which parameter was decrypted most frequently in the last week? Which parameter had its decrypt events spike in the last hour?

Ship both streams to a SIEM or to CloudWatch Logs Insights and run queries regularly. The query I run weekly: top fifty parameters by decrypt count, grouped by principal. Anomalies jump out immediately. A backup job suddenly reading production credentials it has never read before, for example, is visible in this query before it is visible anywhere else.

Secure parameters in CloudFormation and CDK

A common pattern is to reference a Parameter Store value from a CloudFormation template: {{resolve:ssm-secure:my-db-password:1}}. This resolves the SecureString at deploy time and embeds the plaintext value into whatever resource property consumes it.

Two things to know:

Version pinning. The trailing :1 in the reference is the parameter version. If you omit the version ({{resolve:ssm-secure:my-db-password}}), CloudFormation fetches the latest version, which means a parameter update can change your stack without a deploy. Pin the version in production stacks.

Resource-specific resolution. CloudFormation can only resolve ssm-secure in certain resource properties; AWS restricts this to prevent SecureString values from ending up in resource outputs or in other properties where they would be visible. Know which properties are eligible and do not try to use ssm-secure elsewhere.

For many cases, the better pattern is: do not resolve the SecureString at deploy time. Pass the parameter name to the application, and have the application fetch the SecureString at runtime with its own role. This avoids embedding the value in CloudFormation at all and scopes the decrypt to the runtime role.

The Standard vs Advanced decision

Standard tier parameters are free, limited to 4KB, and do not support parameter policies. Advanced tier parameters cost about ten cents each per month, support 8KB, and support policies.

For production secrets, use Advanced tier and the policies that come with it. The cost is negligible and the rotation discipline it enables is worth more than the cost. For operational configuration and non-sensitive parameters, Standard is fine.

If you are comparing against Secrets Manager: Secrets Manager is forty cents per secret per month and includes rotation automation, cross-region replication, and a better access control model for secret sharing. For workloads where Secrets Manager's features matter, pay for Secrets Manager. Parameter Store is the right tool when you want the cheap option and are willing to build your own rotation.

How Safeguard Helps

Safeguard inventories every Parameter Store parameter across your AWS organization, flags String parameters whose names suggest they should be SecureString, and identifies parameters protected only by the default alias/aws/ssm key rather than a scoped customer-managed key. We map IAM policies granting Parameter Store access and surface principals with overly broad ssm:GetParameter on Resource: "*". For parameter rotation, Safeguard tracks parameter last-modified timestamps and alerts on credentials that have not rotated within your policy window, closing the loop between secret inventory and rotation hygiene.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.