Photo by Zoshua Colah on Unsplash
SSM parameter SecureString vs String comes down to encryption: a SecureString value is encrypted with an AWS KMS key and only returned in plain text to callers who ask for decryption and may use the key, while a String value is stored and returned as plain text to anyone with ssm:GetParameter. Passwords, tokens and keys belong in SecureString.
Plaintext secrets end up in Parameter Store the easy way: a quick put-parameter without --type SecureString, a Terraform module with the wrong default, a connection string pasted into a config path. They work, so nobody notices, and every role that can read configuration can read the password too.
This example is for engineers cleaning up Parameter Store. It lists String and StringList parameters whose names or values look like secrets, without ever printing a value, and it can migrate the ones you name to SecureString. For reading parameters from application code, the guide to get SSM parameters with AWS SDK v3 covers WithDecryption, caching and when to use Secrets Manager instead.
SSM parameter SecureString vs String: what’s the difference?
| String / StringList | SecureString | |
|---|---|---|
| Value at rest | Plain text | Encrypted with a symmetric KMS key (aws/ssm or yours) |
What GetParameter returns |
The value | Ciphertext, or plain text with WithDecryption: true |
| Who can read the value | Anyone with ssm:GetParameter |
Also needs kms:Decrypt on a customer managed key |
| Parameter Store charge | None on the standard tier | None on the standard tier; KMS charges can apply |
| History | Up to 100 versions, values readable | Up to 100 versions, values encrypted |
Only the value is encrypted. Names, descriptions and tags stay in plain text, so don’t put secrets there either. Standard-tier SecureString values are encrypted directly with KMS Encrypt; advanced-tier values use envelope encryption with a data key from GenerateDataKey. Storing sensitive values encrypted at rest is a baseline control in most frameworks, for example SC-28, protection of information at rest, in NIST SP 800-53 Rev. 5.
Why not just overwrite the value as SecureString?
Because the old versions remain. Parameter Store keeps up to 100 versions per parameter, and you can view the values of every version in its history, so the plaintext password would still sit in versions 1 to 4 after you encrypt version 5. The script deletes the parameter and creates it again under the same name instead, then reads the new history with GetParameterHistory and reports how many non-encrypted versions remain, so you can confirm the result rather than assume it. Parameter Store asks you to wait at least 30 seconds after a delete before reusing the name, which is why a migration takes a little over half a minute per parameter.
What does the script do?
- Lists plaintext parameters
paginateDescribeParameterswith aTypefilter forStringandStringList. AMI ID parameters (aws:ec2:image) are skipped. - Checks namesFlags names whose last segment contains password, secret, token, API key, private key, credential, connection string, DSN or auth.
- Checks values without showing them
GetParameterwithWithDecryption: false, then pattern checks in memory: AWS access key IDs, private key blocks, JWTs, URLs with an embedded password, GitHub and Slack tokens,password=pairs. Only the reason and the value’s length are printed.--names-onlyskips this step. - Migrates, if askedWith
--apply --names: writes an encrypted backup copy, deletes the parameter, waits 35 seconds, recreates it asSecureStringwith the same tier, description, allowed pattern and tags, compares the decrypted value in memory, counts non-encrypted versions left in history, and deletes the backup.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-ssm. - A profile per task, as in the guide to AWS SDK v3 credential providers: a reader for the report and a separate one for
--apply. - A list of what reads each parameter you plan to migrate. Consumers must request decryption afterwards.
Which IAM permissions does it need?
The first two statements cover the report. The third is only for --apply. The KMS statement is only needed with --key-id and a customer managed key; you can’t write IAM policies for the aws/ssm AWS managed key.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListParameters",
"Effect": "Allow",
"Action": "ssm:DescribeParameters",
"Resource": "*"
},
{
"Sid": "ReadPlaintextValuesInMemory",
"Effect": "Allow",
"Action": "ssm:GetParameter",
"Resource": "arn:aws:ssm:*:*:parameter/*"
},
{
"Sid": "MigrateOnlyWithApply",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:ListTagsForResource",
"ssm:AddTagsToResource",
"ssm:GetParameterHistory"
],
"Resource": "arn:aws:ssm:*:*:parameter/*"
},
{
"Sid": "CustomerManagedKeyOnlyWithKeyId",
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
]
}
Scope the parameter ARNs to a path such as parameter/orders/* when you can. The IAM policy generator for TypeScript code lists the actions any changed version of the script calls.
The script to find plaintext SSM parameters
// find-plaintext-ssm-parameters.ts
// Finds String and StringList parameters in Parameter Store that look like secrets, by name and by value
// pattern. Values are only examined in memory and are never printed, logged or written to disk.
// With --apply --names a,b it migrates the named parameters to SecureString: a SecureString backup copy,
// delete, wait, recreate as SecureString with the same name, tier, description and tags, verify, then remove
// the backup.
// Usage:
// npx tsx find-plaintext-ssm-parameters.ts [--region us-east-1] [--names-only]
// npx tsx find-plaintext-ssm-parameters.ts --region us-east-1 --apply --names /app/db/password [--key-id alias/my-key]
import {
SSMClient,
DeleteParameterCommand,
GetParameterCommand,
ListTagsForResourceCommand,
PutParameterCommand,
paginateDescribeParameters,
paginateGetParameterHistory,
type ParameterMetadata,
} from "@aws-sdk/client-ssm";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const namesOnly = args.includes("--names-only");
const apply = args.includes("--apply");
const toMigrate = (flag("--names") ?? "").split(",").map((n) => n.trim()).filter(Boolean);
const keyId = flag("--key-id"); // omit to use the AWS managed key aws/ssm
const ssm = new SSMClient({ region });
const NAME_HINT = /pass(word|wd)?|secret|token|api[-_]?key|private[-_]?key|credential|client[-_]?secret|conn(ection)?[-_]?str|dsn|auth/i;
const VALUE_HINTS: [RegExp, string][] = [
[/\b(AKIA|ASIA)[A-Z0-9]{16}\b/, "AWS access key ID"],
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "private key block"],
[/\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/, "JWT"],
[/[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@]+@/i, "URL with embedded password"],
[/\b(ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}/, "GitHub token"],
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/, "Slack token"],
[/(password|pwd|secret)\s*[=:]\s*\S+/i, "password=... pair"],
];
interface Row {
Name: string;
Type: string;
Tier: string;
Version: number;
Reasons: string;
Value: string;
}
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Reads a plaintext value. The caller must never print it.
async function readValue(name: string): Promise<string> {
const out = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: false }));
return out.Parameter?.Value ?? "";
}
async function inspect(p: ParameterMetadata): Promise<Row | undefined> {
const name = p.Name ?? "";
const reasons: string[] = [];
if (NAME_HINT.test(name.split("/").pop() ?? name)) reasons.push("name");
let length = "not read";
if (!namesOnly && p.DataType !== "aws:ec2:image") {
const value = await readValue(name);
length = `${value.length} chars`;
for (const [re, label] of VALUE_HINTS) if (re.test(value)) reasons.push(label);
}
if (!reasons.length) return undefined;
return { Name: name, Type: p.Type ?? "?", Tier: p.Tier ?? "?", Version: p.Version ?? 0, Reasons: reasons.join(", "), Value: `[hidden, ${length}]` };
}
async function report(): Promise<Map<string, ParameterMetadata>> {
const plain = new Map<string, ParameterMetadata>();
const filters = [{ Key: "Type", Option: "Equals", Values: ["String", "StringList"] }];
for await (const page of paginateDescribeParameters({ client: ssm }, { ParameterFilters: filters })) {
for (const p of page.Parameters ?? []) if (p.Name) plain.set(p.Name, p);
}
const rows: Row[] = [];
for (const p of plain.values()) {
const row = await inspect(p);
if (row) rows.push(row);
}
console.table(rows);
console.log(`${plain.size} String/StringList parameters in ${region}, ${rows.length} look like secrets. Values are never shown.`);
return plain;
}
async function migrate(meta: ParameterMetadata): Promise<void> {
const name = meta.Name ?? "";
if (meta.Policies?.length) throw new Error("has parameter policies; migrate it by hand");
const value = await readValue(name);
try {
await migrateValue(meta, name, value);
} catch (err) {
// Rethrow with the value masked, in case an error message ever echoes request data.
const text = errText(err);
throw new Error(value ? text.split(value).join("[hidden]") : text);
}
}
async function migrateValue(meta: ParameterMetadata, name: string, value: string): Promise<void> {
const backup = `${name}.migrating`;
const tags = (await ssm.send(new ListTagsForResourceCommand({ ResourceType: "Parameter", ResourceId: name }))).TagList;
const common = { Type: "SecureString" as const, KeyId: keyId, Tier: meta.Tier === "Advanced" ? "Advanced" as const : "Standard" as const };
// 1. Encrypted safety copy, so the value survives a failure after the delete.
await ssm.send(new PutParameterCommand({ ...common, Name: backup, Value: value, Description: `Backup of ${name} during SecureString migration` }));
// 2. Delete the plaintext parameter (and with it the plaintext versions).
await ssm.send(new DeleteParameterCommand({ Name: name }));
// 3. Parameter Store asks for at least 30 seconds before a deleted name is reused.
await sleep(35_000);
// 4. Recreate under the same name as SecureString.
await ssm.send(new PutParameterCommand({
...common,
Name: name,
Value: value,
Description: meta.Description,
AllowedPattern: meta.AllowedPattern,
Tags: tags?.length ? tags : undefined,
}));
// 5. Verify without printing: decrypt and compare in memory, and check every version in history is encrypted.
const check = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
if (check.Parameter?.Type !== "SecureString" || check.Parameter.Value !== value) {
throw new Error(`verification failed; the value is still in ${backup}`);
}
let plainVersions = 0;
for await (const page of paginateGetParameterHistory({ client: ssm }, { Name: name, WithDecryption: false })) {
plainVersions += (page.Parameters ?? []).filter((h) => h.Type !== "SecureString").length;
}
// 6. Remove the backup copy.
await ssm.send(new DeleteParameterCommand({ Name: backup }));
console.log(`Migrated ${name} to SecureString (${common.Tier}); plaintext versions left in history: ${plainVersions}`);
}
async function main(): Promise<void> {
const plain = await report();
if (!apply) {
console.log("Report only: nothing was modified. Use --apply --names <a,b> to migrate specific parameters.");
return;
}
if (!toMigrate.length) throw new Error("--apply needs --names with the parameters to migrate");
for (const name of toMigrate) {
const meta = plain.get(name);
if (!meta) {
console.error(`Skipping ${name}: not a String/StringList parameter in ${region}`);
continue;
}
try {
await migrate(meta);
} catch (err) {
console.error(`Could not migrate ${name}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
Never print values: the script keeps each value in a local variable, prints only a reason and a length, and masks the value in any error message. If you adapt it, don’t add the value to a log line, a CSV or a console.table for debugging. Terminal scrollback and CI logs are exactly where leaked secrets are found.
How do you run it?
npm install @aws-sdk/client-ssm
npm install --save-dev tsx typescript @types/node
# Report only (reads String values in memory, never prints them)
AWS_PROFILE=readonly npx tsx find-plaintext-ssm-parameters.ts --region us-east-1
# Names only: no GetParameter calls at all
AWS_PROFILE=readonly npx tsx find-plaintext-ssm-parameters.ts --region us-east-1 --names-only
# Migrate one parameter to SecureString with the aws/ssm key
AWS_PROFILE=params-admin npx tsx find-plaintext-ssm-parameters.ts --region us-east-1 --apply --names /orders/prod/db-url
Sample output
┌─────────┬──────────────────────────────┬──────────┬────────────┬─────────┬──────────────────────────────┬──────────────────────┐
│ (index) │ Name │ Type │ Tier │ Version │ Reasons │ Value │
├─────────┼──────────────────────────────┼──────────┼────────────┼─────────┼──────────────────────────────┼──────────────────────┤
│ 0 │ '/orders/prod/db-url' │ 'String' │ 'Standard' │ 4 │ 'URL with embedded password' │ '[hidden, 52 chars]' │
│ 1 │ '/shared/ci/deploy-user-key' │ 'String' │ 'Standard' │ 1 │ 'AWS access key ID' │ '[hidden, 20 chars]' │
└─────────┴──────────────────────────────┴──────────┴────────────┴─────────┴──────────────────────────────┴──────────────────────┘
4 String/StringList parameters in us-east-1, 2 look like secrets. Values are never shown.
Migrated /orders/prod/db-url to SecureString (Standard); plaintext versions left in history: 0
Names are illustrative. /orders/prod/db-url holds a connection string with a password, and was migrated. /shared/ci/deploy-user-key holds an AWS access key ID; its secret key is probably in another parameter or a CI variable, and moving it to SecureString isn’t enough. Deactivate and replace the key, and use the script to find old and unused IAM access keys to see where else it’s used.
What changes for applications after the migration?
- Readers must decrypt. Code calling
GetParameterwithoutWithDecryption: truenow gets ciphertext. Passing it for every call is harmless, since the flag is ignored for other types. - Customer managed keys need
kms:Decrypt. Add it to every role that reads the parameter, or use theaws/ssmkey. - ECS and Lambda references keep working if they already read through the SDK or ECS task definition
secrets. Values copied into plain environment variables don’t; the scripts to find secrets in ECS task definitions and find secrets in Lambda environment variables catch those copies. - CloudFormation can’t create
SecureStringparameters. If a stack owns the parameter, take it out of the template before you migrate, or the stack drifts from what’s deployed and a later update can undo the change.
Standard or advanced tier?
Standard parameters hold up to 4 KB, up to 10,000 per Region, at no extra charge. Advanced parameters hold up to 8 KB, support parameter policies such as expiration, and are charged. You can move a standard parameter to advanced at any time, but never back; to leave advanced, delete and recreate. The script keeps the existing tier.
Troubleshooting
- The recreate step fails right after the delete. The name was reused too soon or the profile lacks
ssm:PutParameter. The value is safe in<name>.migratingas aSecureString; fix the cause and create the parameter from it. - A parameter is skipped with “has parameter policies”. Parameters with policies are advanced-tier and need their policies re-attached; migrate those by hand.
- False positives. Names containing “auth” or “token” can be harmless, such as an OAuth issuer URL. Review before you migrate; migrating a non-secret costs nothing but a decrypt flag.
Once values are encrypted, keep the key healthy: check KMS key rotation for customer managed keys. Credentials that should rotate on a schedule belong in Secrets Manager; the script to find Secrets Manager secrets without rotation checks those. Secrets also turn up inside the scripts of Systems Manager documents, which is a bigger problem when a document is public; the script to find publicly shared SSM documents scans those for secret-looking strings.
Ask ChatWithCloud instead
To see which parameters are plaintext without reading any values, ask ChatWithCloud “List the String and StringList parameters under /orders in us-east-1.” It writes AWS SDK for JavaScript v2 code, runs it with your profile on your machine and summarizes the metadata; the JSON returned by the calls is sent to the model, so don’t ask it to read secret values. ChatWithCloud’s security model explains what leaves your machine, and connecting ChatWithCloud to a read-only profile keeps it from changing anything, since it runs changes without confirmation.
Frequently asked questions
What is the difference between SSM parameter SecureString vs String?
A SecureString value is encrypted with a KMS key and decrypted only on request; a String value is stored and returned in plain text. Use SecureString for any password, token or key.
Does SecureString cost more than String?
Parameter Store doesn’t charge extra for SecureString on the standard tier. AWS KMS charges can apply for the encrypt and decrypt calls, and advanced-tier parameters are charged either way.
Is the parameter name encrypted in a SecureString?
No. Only the value is encrypted. The name, description and tags are plain text.
Why delete and recreate instead of overwriting?
Parameter history keeps up to 100 earlier versions with readable values. Overwriting would leave the plaintext secret in those versions. The script deletes and recreates the parameter, then checks the new history for any version that isn’t SecureString.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud