Back to blog
AWSBest PracticesCloud SecurityIdentity & Access

SSM Parameter Not Using SecureString: Stop Storing Secrets in Plaintext

Learn why sensitive SSM parameters stored as plain String are a security risk, how to convert them to SecureString with KMS, and how to prevent it in CI/CD.

TL;DR

This check flags SSM parameters with sensitive-sounding names (password, token, secret, key) that are stored as plain String instead of SecureString. Plain strings are not encrypted at rest by KMS and show up in plaintext to anyone with read access. The fix is to recreate the parameter as a SecureString backed by a KMS key and tighten IAM access.

Parameter Store is the quiet workhorse of AWS configuration. It is cheap, simple, and gets used for everything from feature flags to database connection strings. That last part is where trouble starts. Storing a database password or an API token as a plain String parameter is one of the most common ways credentials end up readable by people and roles who never should have had them.

The ssm_nosecurestring check looks for parameters whose names suggest they hold sensitive data, then verifies whether they were actually created with the protection that data deserves.


What this check detects

AWS Systems Manager Parameter Store supports three parameter types:

  • String — plaintext value, stored and returned as-is
  • StringList — comma-separated plaintext values
  • SecureString — value encrypted at rest using AWS KMS

The check scans your parameters and matches names against patterns that commonly indicate secrets: password, passwd, secret, token, apikey, api_key, private_key, credential, and similar. If a parameter matches one of those patterns but has a type of String or StringList, Lensix flags it.

Note: The check is heuristic. It catches parameters that look sensitive based on naming. A parameter called /app/db/password stored as plain String is an obvious hit. A secret hiding in a parameter named /app/config/value42 will not be caught by name alone, which is why naming conventions matter for more than readability.


Why it matters

A plain String parameter has no encryption at rest beyond the default disk-level encryption AWS applies to its storage. More importantly, the value is returned in plaintext to anyone with ssm:GetParameter permissions, and it shows up unredacted in a lot of places you might not expect.

Where plaintext parameters leak

  • CloudTrail and console history — calls that reference parameters, plus anyone browsing the Parameter Store console, see values directly.
  • Broad IAM grants — a role with ssm:GetParameter* on * can read every plaintext parameter in the account. SecureString adds a second gate: the caller also needs kms:Decrypt on the key.
  • CloudFormation and Terraform state — if a String parameter feeds an IaC pipeline, the value can land in state files and template outputs.
  • Logs and crash dumps — applications that pull a plain String and log their config dump the secret right into your log aggregator.

The realistic attack scenario is lateral movement. An attacker compromises a low-privilege EC2 instance or Lambda function whose role has ssm:GetParametersByPath on a broad prefix. With SecureString and a scoped KMS policy, that role gets encrypted blobs it cannot decrypt. With plain String, it gets your production database password in cleartext on the first API call.

Warning: SecureString protects the value, but the parameter name is always plaintext. Never put secrets in parameter names, and remember that a name like /prod/stripe/live-secret-key still tells an attacker exactly what to go after.


How to fix it

You cannot change a parameter's type in place. A String cannot be converted to a SecureString with an update. You have to recreate it. The workflow below preserves the name and path.

1. Confirm the current value and type

aws ssm get-parameter \
  --name "/app/db/password" \
  --with-decryption \
  --query "Parameter.{Type:Type,Value:Value}" \
  --output table

2. Recreate it as a SecureString

Use --overwrite with a new type. If you do not specify --key-id, SSM uses the AWS-managed key alias/aws/ssm.

aws ssm put-parameter \
  --name "/app/db/password" \
  --value "the-actual-secret-value" \
  --type SecureString \
  --key-id "alias/my-app-key" \
  --overwrite

Warning: Overwriting a parameter increments its version and replaces the value for every consumer immediately. If anything reads this parameter at boot time only, plan a restart or redeploy so running services pick up the change. Test in a non-production path first.

3. Use a customer-managed KMS key (recommended)

The default alias/aws/ssm key cannot have its policy customized, so anyone with ssm:GetParameter and decrypt rights through that key can read the value. A customer-managed key (CMK) lets you scope kms:Decrypt to specific roles.

aws kms create-key \
  --description "SSM SecureString key for app secrets" \
  --query "KeyMetadata.KeyId" --output text

aws kms create-alias \
  --alias-name "alias/my-app-key" \
  --target-key-id ""

Then restrict who can decrypt by attaching a key policy that only grants kms:Decrypt to the roles that genuinely need the parameter.

4. Verify the result

aws ssm describe-parameters \
  --parameter-filters "Key=Name,Values=/app/db/password" \
  --query "Parameters[0].{Name:Name,Type:Type,KeyId:KeyId}"

The Type should now read SecureString with your key ID present.

Fixing it in Terraform

resource "aws_ssm_parameter" "db_password" {
  name   = "/app/db/password"
  type   = "SecureString"
  key_id = aws_kms_key.app.arn
  value  = var.db_password

  # Avoid drift if the value rotates outside Terraform
  lifecycle {
    ignore_changes = [value]
  }
}

Danger: Do not hardcode the secret value into your Terraform code or commit it to version control. Pass it through a variable sourced from a secrets manager, an environment variable, or a CI secret. A SecureString parameter defined with a plaintext value in .tf files just moves the leak from Parameter Store to your Git history.


How to prevent it from happening again

Manual cleanups fix today's problem. Guardrails stop the next one.

Block plain Strings in IaC review

If you use Terraform, an OPA/Conftest policy can reject any aws_ssm_parameter with a sensitive name that is not a SecureString.

package terraform.ssm

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_ssm_parameter"
  name := resource.change.after.name
  re_match("(?i)(password|secret|token|api_?key|credential|private_key)", name)
  resource.change.after.type != "SecureString"
  msg := sprintf("SSM parameter '%s' has a sensitive name but is not a SecureString", [name])
}

Wire it into CI so the pipeline fails before the parameter ever reaches AWS:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policy/

Tip: Run the same policy logic against your live account on a schedule, not just at deploy time. People create parameters by hand in the console during incidents, and those never pass through CI. Lensix runs ssm_nosecurestring continuously so console-created drift gets caught automatically.

Detect it with AWS Config

If you want a native control, the managed AWS Config rule ssm-parameter-not-encrypted (where available) or a custom Lambda-backed rule can evaluate parameter types and flag non-encrypted secrets in near real time.

Use SCPs to require encryption context

For high-security accounts, a Service Control Policy can deny ssm:PutParameter when the request is not a SecureString. This is blunt, so apply it to accounts where every secret-like parameter genuinely should be encrypted.


Best practices

  • Default to SecureString for anything remotely sensitive. The cost difference is negligible and the downside of guessing wrong is a leaked credential.
  • Prefer customer-managed KMS keys over alias/aws/ssm so you control who can decrypt, separate from who can read.
  • Scope IAM tightly. Grant ssm:GetParameter on specific paths, not *, and pair it with a matching kms:Decrypt grant on the relevant key only.
  • Adopt a path convention. A scheme like /{env}/{app}/{component} makes IAM scoping and the heuristic naming check far more effective.
  • Consider Secrets Manager for rotating credentials. Parameter Store SecureString is great for static secrets. For database passwords that need automatic rotation, Secrets Manager handles the rotation lifecycle for you.
  • Audit existing parameters regularly. Use describe-parameters filtered by type to find every plain String and review which ones should be promoted.

The goal is not just to encrypt the values you know are sensitive. It is to build an environment where storing a secret in plaintext requires actively working around a guardrail, instead of being the default that happens when someone is in a hurry.

Fix the parameters this check flags, then put the IaC policy and continuous scan in place so the same mistake cannot quietly return.