Find Secrets in Lambda Environment Variables

A padlock resting on a printed circuit board

Photo by Harrison Broadbent on Unsplash

To find secrets in Lambda environment variables, page through ListFunctions in every Region and test each entry in Environment.Variables two ways: does the value match a known credential format (an AKIA access key ID, a GitHub token, a PEM private key, a URL with a password), and does a secret-sounding name hold a literal value? Report the variable names only, never the values.

Environment variables are the fastest way to get a database password into a function, so they collect credentials that were meant to be temporary. Lambda encrypts them at rest, but anyone allowed to read the function’s configuration sees them in plaintext, and so do the console, your deployment templates and any tool that dumps the configuration. This example is for engineers who want to find secrets in Lambda environment variables across an account and move them somewhere safer.

You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that lists every suspicious variable with a severity and a masked hint. It is report-only and, by design, it never prints, logs or returns a secret value, like the other AWS SDK v3 security audit scripts in this collection.

Why are secrets in Lambda environment variables a problem?

AWS’s own Lambda guide recommends Secrets Manager instead of environment variables for database credentials, API keys and tokens. The reasons are practical:

  • Read access is broad. lambda:GetFunctionConfiguration and lambda:ListFunctions return the values. Plenty of read-only and developer roles have both.
  • Published versions freeze them. When you publish a version, its environment variables are locked with it. Removing a key from $LATEST leaves the old value in every earlier version until you delete those versions.
  • No rotation. A value in configuration changes only when someone redeploys, so leaked credentials stay valid for as long as they stay in the variable.
  • They spread. The same value tends to sit in a CloudFormation template, a CI variable and a teammate’s shell history.

The OWASP Secrets Management Cheat Sheet covers the general case: centralize secrets, give each workload the narrowest access to them, and rotate them.

What does the script look for?

  1. Lists Regions and functionsDescribeRegions, then ListFunctions with the SDK paginator in each Region. --all-versions sets FunctionVersion to ALL so published versions are scanned too.
  2. Matches known credential formats (HIGH)AWS access key IDs start with AKIA (long-term) or ASIA (temporary). GitHub tokens start with ghp_, gho_, ghu_, ghs_, ghr_ or github_pat_, as listed in GitHub’s token prefix reference. It also flags PEM private keys, JWTs, connection URLs with an embedded password and password= strings.
  3. Checks secret-sounding names (MEDIUM)Names containing PASSWORD, SECRET, TOKEN, API_KEY, DATABASE_URL and similar, when the value is 8+ characters and not true, a number or a reference.
  4. Flags random-looking values (LOW)24+ characters with high Shannon entropy. These are often keys under a harmless name, and sometimes just hashes.
  5. Skips referencesValues that are Secrets Manager, Parameter Store or KMS ARNs, or parameter paths like /prod/db/password, are pointers to a secret, not the secret.

How does it avoid leaking what it finds?

The scanner holds values in memory only long enough to test them. The output row type has no value field at all; the Hint column is built from the length plus, for fixed-format credentials, the public prefix (AKIA, ghp_). Error handlers print the error name, not the payload. Even so, run it in a terminal you trust: the credentials that let it read configuration also let it read the values.

Prerequisites

Which IAM permissions does it need?

ListFunctions doesn’t support resource-level permissions, so the policy uses "*". There is no write statement because the script never changes a function.

lambda-secrets-scan-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadFunctionConfiguration",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "lambda:ListFunctions"
      ],
      "Resource": "*"
    }
  ]
}

Functions encrypted with a customer managed KMS key are different: only principals allowed to use that key with kms:Decrypt can view their environment variables. Without it, the script reports the function as not readable instead of guessing. The IAM policy generator for TypeScript SDK code drafts a policy if you extend the script.

The script to find secrets in Lambda environment variables

find-secrets-in-lambda-environment-variables.ts

// find-secrets-in-lambda-environment-variables.ts
// Scans the environment variables of every Lambda function in each Region for values that look like
// secrets: AWS access key IDs, GitHub tokens, private keys, JWTs, URLs with embedded passwords, and
// secret-sounding variable names with non-trivial values. Report-only: it never changes a function.
// It NEVER prints a value. Output holds variable names plus a masked hint (known prefix and length).
// Usage: npx tsx find-secrets-in-lambda-environment-variables.ts [--regions=us-east-1,eu-west-1] [--all-versions]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import { LambdaClient, paginateListFunctions, type FunctionConfiguration } from "@aws-sdk/client-lambda";

const args = process.argv.slice(2);
const allVersions = args.includes("--all-versions");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);

type Severity = "HIGH" | "MEDIUM" | "LOW";
interface Finding {
  Region: string;
  Function: string;
  Version: string;
  Variable: string;
  Severity: Severity;
  Reason: string;
  Hint: string; // masked: never contains the secret
  EnvKey: string;
}

// Value patterns. "prefix" is a fixed, public prefix that is safe to show in the hint.
const VALUE_PATTERNS: { kind: string; re: RegExp; prefix?: (v: string) => string }[] = [
  { kind: "AWS access key ID", re: /^(AKIA|ASIA)[A-Z0-9]{16}$/, prefix: (v) => v.slice(0, 4) },
  { kind: "GitHub token", re: /^(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}$/, prefix: (v) => v.slice(0, v.indexOf("_") + 1) },
  { kind: "private key (PEM)", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, prefix: () => "-----BEGIN" },
  { kind: "JWT", re: /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, prefix: () => "eyJ" },
  { kind: "URL with password", re: /^[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@/i },
  { kind: "password= in value", re: /(password|passwd|pwd|secret|token)\s*[=:]\s*\S{4,}/i },
];
const SECRET_NAME = /(pass(word|wd)?|pwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key|credential|auth|client[_-]?secret|signing[_-]?key|conn(ection)?[_-]?str|dsn|database[_-]?url)/i;
// Values that point at a secret instead of containing one are fine.
const REFERENCE = /^(arn:aws[a-z-]*:(secretsmanager|ssm|kms):|\{\{resolve:|\/[A-Za-z0-9_.\/-]+$)/;
const HARMLESS = /^(true|false|yes|no|on|off|enabled|disabled|none|null|\d+)$/i;

function entropy(s: string): number {
  const counts = new Map<string, number>();
  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
  let h = 0;
  for (const n of counts.values()) h -= (n / s.length) * Math.log2(n / s.length);
  return h;
}

// Builds the only thing about a value that is ever printed: a public prefix (if any) and the length.
function mask(value: string, prefix?: string): string {
  return `${prefix ?? ""}${"*".repeat(8)} (${value.length} chars)`;
}

function classify(name: string, value: string): { severity: Severity; reason: string; hint: string } | undefined {
  const v = value.trim();
  if (!v || REFERENCE.test(v)) return undefined;
  for (const p of VALUE_PATTERNS) {
    if (p.re.test(v)) return { severity: "HIGH", reason: p.kind, hint: mask(v, p.prefix?.(v)) };
  }
  if (SECRET_NAME.test(name) && v.length >= 8 && !HARMLESS.test(v)) {
    return { severity: "MEDIUM", reason: "secret-like name with a literal value", hint: mask(v) };
  }
  if (v.length >= 24 && !/\s/.test(v) && entropy(v) >= 4.0) {
    return { severity: "LOW", reason: "long random-looking value", hint: mask(v) };
  }
  return undefined;
}

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg;
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

function scanFunction(region: string, fn: FunctionConfiguration): Finding[] {
  const base = {
    Region: region,
    Function: fn.FunctionName ?? "?",
    Version: fn.Version ?? "$LATEST",
    EnvKey: fn.KMSKeyArn ? "customer managed KMS key" : "AWS managed key",
  };
  if (fn.Environment?.Error) {
    // Typically a customer managed KMS key the caller can't use: the values aren't returned.
    return [{ ...base, Variable: "-", Severity: "LOW", Reason: `not readable: ${fn.Environment.Error.ErrorCode ?? "error"}`, Hint: "-" }];
  }
  const findings: Finding[] = [];
  for (const [name, value] of Object.entries(fn.Environment?.Variables ?? {})) {
    const hit = classify(name, value);
    if (hit) findings.push({ ...base, Variable: name, Severity: hit.severity, Reason: hit.reason, Hint: hit.hint });
  }
  return findings;
}

async function main(): Promise<void> {
  const findings: Finding[] = [];
  let scanned = 0;
  for (const region of await listRegions()) {
    const lambda = new LambdaClient({ region });
    try {
      const input = allVersions ? { FunctionVersion: "ALL" as const } : {};
      for await (const page of paginateListFunctions({ client: lambda }, input)) {
        for (const fn of page.Functions ?? []) {
          scanned++;
          findings.push(...scanFunction(region, fn));
        }
      }
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
    }
  }

  const order: Record<Severity, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
  findings.sort((a, b) => order[a.Severity] - order[b.Severity] || a.Function.localeCompare(b.Function));
  console.table(findings);
  const high = findings.filter((f) => f.Severity === "HIGH").length;
  console.log(`${scanned} function version(s) scanned; ${findings.length} finding(s), ${high} HIGH. No values were printed.`);
  if (high) process.exitCode = 2; // lets CI or a scheduled job fail on a finding
}

main().catch((err) => {
  console.error(err instanceof Error ? err.name + ": " + err.message : err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-lambda @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# $LATEST of every function in every enabled Region
AWS_PROFILE=security-audit npx tsx find-secrets-in-lambda-environment-variables.ts

# Include every published version, two Regions only
AWS_PROFILE=security-audit npx tsx find-secrets-in-lambda-environment-variables.ts --regions=us-east-1,eu-west-1 --all-versions

The script exits with code 2 when it finds a HIGH match, so a scheduled job or CI step fails instead of printing a table nobody reads. To follow the trend across runs, the job can publish the number of findings as a metric, as in the guide to publish custom CloudWatch metrics with AWS SDK v3 PutMetricData, and alarm when it rises above zero. ListFunctions returns at most 50 functions per page; the paginator handles that.

Sample output

Output

┌─────────┬─────────────┬───────────────────┬───────────┬─────────────────────┬──────────┬─────────────────────────────────────────┬───────────────────────────┬────────────────────────────┐
│ (index) │ Region      │ Function          │ Version   │ Variable            │ Severity │ Reason                                  │ Hint                      │ EnvKey                     │
├─────────┼─────────────┼───────────────────┼───────────┼─────────────────────┼──────────┼─────────────────────────────────────────┼───────────────────────────┼────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'billing-sync'    │ '$LATEST' │ 'LEGACY_AWS_KEY_ID' │ 'HIGH'   │ 'AWS access key ID'                     │ 'AKIA******** (20 chars)' │ 'AWS managed key'          │
│ 1       │ 'us-east-1' │ 'deploy-notifier' │ '$LATEST' │ 'GH_TOKEN'          │ 'HIGH'   │ 'GitHub token'                          │ 'ghp_******** (40 chars)' │ 'AWS managed key'          │
│ 2       │ 'us-east-1' │ 'orders-api'      │ '$LATEST' │ 'DATABASE_URL'      │ 'HIGH'   │ 'URL with password'                     │ '******** (61 chars)'     │ 'customer managed KMS key' │
│ 3       │ 'us-east-1' │ 'orders-api'      │ '$LATEST' │ 'STRIPE_API_KEY'    │ 'MEDIUM' │ 'secret-like name with a literal value' │ '******** (32 chars)'     │ 'customer managed KMS key' │
│ 4       │ 'us-east-1' │ 'image-resize'    │ '$LATEST' │ 'CDN_SIGNING_SALT'  │ 'LOW'    │ 'long random-looking value'             │ '******** (44 chars)'     │ 'AWS managed key'          │
│ 5       │ 'us-west-2' │ 'payroll-export'  │ '$LATEST' │ '-'                 │ 'LOW'    │ 'not readable: AccessDeniedException'   │ '-'                       │ 'customer managed KMS key' │
└─────────┴─────────────┴───────────────────┴───────────┴─────────────────────┴──────────┴─────────────────────────────────────────┴───────────────────────────┴────────────────────────────┘
38 function version(s) scanned; 6 finding(s), 3 HIGH. No values were printed.

The names are illustrative. billing-sync holds a long-term access key ID, which also means an IAM user’s secret key is probably in a neighboring variable under an innocent name; find that user with the script to find IAM access keys older than 90 days or never used and deactivate the key once the function uses its execution role instead. payroll-export uses a customer managed key this profile can’t use, so review it with a role that can.

How do you move the secrets out?

  1. Rotate firstTreat every HIGH and MEDIUM value as exposed. Issue a new credential at the provider, because the old one has been visible to everyone with read access.
  2. Store it properlyPut the new value in Secrets Manager, or in Parameter Store as a SecureString. Keep only the secret’s name or ARN in the environment variable. Secrets that no function reads anymore show up in the script to find unused Secrets Manager secrets.
  3. Read it at runtimeFetch the value during initialization and cache it; the guides to get a Secrets Manager secret value with AWS SDK v3 and read SSM Parameter Store values with AWS SDK v3 show the code and the execution role permissions.
  4. Delete old versionsPublished versions still carry the old value. The script to delete old and unused Lambda function versions removes them without touching versions that aliases point to.

If you do keep sensitive values in environment variables, a customer managed KMS key at least narrows who can read them to principals with kms:Decrypt on that key. Keep an eye on the keys you create; the script to find unused customer managed KMS keys catches the ones nobody uses anymore.

Troubleshooting

  • AccessDeniedException on ListFunctions. The Region row is skipped with the error name on stderr. Check for an SCP that denies Lambda in unused Regions; the guide to troubleshoot AWS IAM access denied errors step by step walks through it.
  • Many LOW findings. Build hashes, feature-flag IDs and public keys look random too. LOW is a prompt to look, not a verdict; tighten the entropy threshold if your functions use many such values.
  • A known secret isn’t flagged. A short value under a name like DB_PASS needs 8+ characters to count, and custom formats aren’t in the pattern list. Add your provider’s prefix to VALUE_PATTERNS.
  • TooManyRequestsException. The SDK retries throttled calls; for very large accounts raise maxAttempts as described in the guide to configure retries and timeouts in AWS SDK for JavaScript v3.

While you’re reviewing functions, the checks to find public Lambda function URLs with AuthType NONE and find Lambda functions on deprecated runtimes cover the other common Lambda exposures. Containers repeat the same mistake in their configuration, and the script to find plaintext secrets in ECS task definitions checks every task definition revision the same way.

Ask ChatWithCloud instead

ChatWithCloud answers questions about your account by writing AWS SDK for JavaScript v2 code, running it on your machine with your profile and sending the JSON result to the AI model to write the answer. That’s why this particular audit belongs in the script: a question that returns environment values would send them for processing. Ask about metadata instead, for example “Which Lambda functions use a customer managed KMS key for environment variables?” Use a read-only AWS profile for ChatWithCloud, and read the ChatWithCloud security model for what leaves your machine. The free code converters run a similar local check before sending anything; the guide on whether it’s safe to paste AWS code into an AI converter explains it.

Frequently asked questions

Are Lambda environment variables encrypted?

Yes, at rest, with an AWS managed key by default or a customer managed KMS key you choose. They are decrypted for anyone allowed to read the function configuration, so encryption at rest doesn’t hide them from your own users.

Is it safe to store an API key in a Lambda environment variable?

It works, but AWS recommends Secrets Manager for credentials and API keys. A stored secret can be rotated and access to it is logged and permissioned separately from the function configuration.

Does removing a variable from $LATEST remove it everywhere?

No. Each published version keeps the environment variables it was published with. Scan with --all-versions and delete versions that still hold the old value.

Can the script show me the value it found?

No, on purpose. It prints the variable name, the reason and a masked hint. Open the function in the console or check your deployment source to see the value, then rotate it.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud