Back to blog
AWSBest PracticesCloud SecurityCompute & ContainersIdentity & Access

Fixing Plaintext Secrets in ECS Task Definitions

Learn why plaintext secrets in ECS task definitions are risky and how to move them to AWS Secrets Manager or SSM Parameter Store with CLI and Terraform steps.

TL;DR

This check flags ECS task definitions that pass secrets like database passwords or API keys as plaintext environment variables, where anyone with ECS read access can see them. Move those values into AWS Secrets Manager or SSM Parameter Store and reference them through the task definition's secrets block instead.

Hardcoding secrets in environment variables is one of those habits that feels harmless during development and turns into a liability the moment your account grows past a handful of people. ECS makes it easy to do the wrong thing: the environment field in a task definition accepts arbitrary key-value pairs, and nothing stops you from dropping a production database password right in there. Lensix raises ecs_plaintextsecrets when it finds task definitions doing exactly that.

This post walks through what the check looks for, why plaintext secrets in task definitions are riskier than they appear, and how to migrate to a proper secrets backend without rewriting your whole deployment pipeline.


What this check detects

Lensix inspects each container definition inside your ECS task definitions and looks at the environment array. That array holds plain key-value pairs that ECS injects into the container at launch. The check flags entries whose names or values look like credentials: anything matching patterns such as PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, connection strings, or high-entropy values that resemble keys.

Here is the kind of definition that trips the check:

{
  "family": "billing-api",
  "containerDefinitions": [
    {
      "name": "billing",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/billing:latest",
      "environment": [
        { "name": "DB_HOST", "value": "billing-db.internal" },
        { "name": "DB_PASSWORD", "value": "S3cretP@ssw0rd!" },
        { "name": "STRIPE_API_KEY", "value": "sk_live_51Hx...redacted" }
      ]
    }
  ]
}

The DB_PASSWORD and STRIPE_API_KEY values are sitting in cleartext. ECS has a dedicated secrets field for exactly this situation, and the check passes when sensitive values live there instead.

Note: The environment field is perfectly fine for non-sensitive configuration like log levels, feature flags, or region names. The check only cares about values that grant access to something.


Why it matters

The core problem is that task definitions are not treated as secrets by AWS, so they show up in places you might not expect. A plaintext value in environment is visible to:

  • Anyone with ecs:DescribeTaskDefinition permissions, which is commonly granted broadly because it seems read-only and harmless.
  • CloudTrail event history, since RegisterTaskDefinition calls log the full request payload.
  • Infrastructure-as-code state files, especially Terraform state, which stores the rendered task definition including the plaintext value.
  • Any CI/CD logs that echo the task definition during a deploy.

That means a single secret can leak through several independent channels, and rotating it requires hunting down every copy. Compare that to a Secrets Manager reference, where the task definition only stores an ARN and the actual value never appears in logs or state.

A leaked database password is bad. A leaked database password that lives in Terraform state, CloudTrail, and three deploy logs is an incident with no clear blast radius.

The real-world failure mode usually goes like this: an engineer leaves the company, IAM gets cleaned up, and someone realizes the read-only "developer" role they all shared had ecs:Describe* the whole time. Now every production credential that ever lived in a task definition has to be rotated, because you cannot prove it was not read.

Warning: Marking a task definition revision as inactive does not remove it. Old revisions with plaintext secrets remain describable until they fall out of retention, so deleting the secret from the latest revision is not enough on its own. You still need to rotate the underlying credential.


How to fix it

The fix has two halves: store the secret in a proper backend, then reference it from the task definition. You have two AWS-native options.

Option 1: AWS Secrets Manager

Secrets Manager is the better choice when you need automatic rotation, cross-account sharing, or built-in integration with RDS. Create the secret first:

aws secretsmanager create-secret \
  --name prod/billing/db-password \
  --secret-string 'S3cretP@ssw0rd!'

Then reference it from the task definition using the secrets field instead of environment:

{
  "family": "billing-api",
  "containerDefinitions": [
    {
      "name": "billing",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/billing:latest",
      "environment": [
        { "name": "DB_HOST", "value": "billing-db.internal" }
      ],
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/billing/db-password"
        }
      ]
    }
  ]
}

ECS resolves the ARN at task launch and injects the value as the DB_PASSWORD environment variable inside the container, so your application code does not need to change.

Option 2: SSM Parameter Store

Parameter Store is cheaper and simpler when you do not need rotation. Use a SecureString parameter so the value is KMS-encrypted at rest:

aws ssm put-parameter \
  --name /prod/billing/db-password \
  --type SecureString \
  --value 'S3cretP@ssw0rd!'

Reference it the same way, using the parameter ARN in valueFrom:

"secrets": [
  {
    "name": "DB_PASSWORD",
    "valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/billing/db-password"
  }
]

Grant the execution role permission to read it

This is the step people forget. The task execution role (not the task role) needs permission to fetch the secret, otherwise the task will fail to start. Attach a policy like this to the execution role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/billing/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/your-key-id"
    }
  ]
}

For SSM SecureString parameters, swap the first action for ssm:GetParameters and keep the kms:Decrypt statement so the parameter can be decrypted.

Danger: Once you confirm the new task definition launches cleanly, rotate the secret value. The old plaintext copy was exposed and should be treated as compromised, even if you have no evidence it was read. Reusing the same string defeats the purpose of the migration.

Terraform example

If you manage ECS with Terraform, here is the equivalent. Note that the secret value comes from a separate resource, so it never appears inline in the task definition:

resource "aws_secretsmanager_secret" "db_password" {
  name = "prod/billing/db-password"
}

resource "aws_ecs_task_definition" "billing" {
  family = "billing-api"

  container_definitions = jsonencode([
    {
      name  = "billing"
      image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/billing:latest"
      environment = [
        { name = "DB_HOST", value = "billing-db.internal" }
      ]
      secrets = [
        {
          name      = "DB_PASSWORD"
          valueFrom = aws_secretsmanager_secret.db_password.arn
        }
      ]
    }
  ])
}

Warning: Do not put the actual secret value in a aws_secretsmanager_secret_version resource with a hardcoded string, or you have just moved the plaintext from the task definition into Terraform state. Set the initial value out of band with the CLI, or use ignore_changes on the version and manage rotation separately.


How to prevent it from happening again

Fixing the existing task definitions is the easy part. Keeping new ones clean requires a guardrail in the pipeline.

Scan in CI before deploy

Add a check that fails the build if a task definition contains suspicious keys in environment. A lightweight version with jq looks like this:

jq -e '
  .containerDefinitions[].environment[]?
  | select(.name | test("PASSWORD|SECRET|TOKEN|API_KEY|PRIVATE_KEY"; "i"))
' taskdef.json && {
  echo "Plaintext secret detected in task definition"; exit 1;
} || echo "No plaintext secrets found"

Policy-as-code with OPA

For Terraform plans, a Conftest or OPA policy enforces the rule across every team without relying on reviewers to catch it:

package ecs

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_ecs_task_definition"
  cd := json.unmarshal(rc.change.after.container_definitions)
  env := cd[_].environment[_]
  regex.match("(?i)(password|secret|token|api_key|private_key)", env.name)
  msg := sprintf("Container env var '%s' looks like a secret; use the secrets block", [env.name])
}

Tip: Run the same Lensix check on a schedule against your live AWS accounts, not just at deploy time. People register task definitions through the console and the CLI outside of CI, and a continuous scan catches the ones that never went through your pipeline.

Block secrets at the source

Pre-commit hooks like gitleaks or detect-secrets stop credentials from ever reaching the repo, which means they cannot reach a task definition either. This pairs well with the CI check above: one stops the secret entering the codebase, the other stops it reaching AWS.


Best practices

  • Use the task execution role for secret access, not the task role. ECS fetches secrets during container provisioning using the execution role. The task role is for your application's own AWS calls at runtime.
  • Scope secret permissions tightly. Grant GetSecretValue on a path prefix like prod/billing/* rather than *, so a compromised execution role cannot read every secret in the account.
  • Pull individual JSON keys when you store structured secrets. Secrets Manager lets you reference a single field with valueFrom ending in :DB_PASSWORD::, so you do not have to inject the whole JSON blob.
  • Enable rotation for database credentials. Secrets Manager rotation combined with RDS means the password changes on a schedule and the task picks up the new value on its next launch.
  • Clean up old task definition revisions. Deregister and, where supported, delete revisions that contained plaintext secrets so they stop showing up in DescribeTaskDefinition.
  • Treat any previously exposed value as burned. Migration is only complete after rotation. The point of the move is to limit exposure, and an unrotated secret still carries its old risk.

The short version: ECS gives you a first-class place to put secrets, and the only reason to use plaintext environment variables for them is that no one stopped you. Move the values into Secrets Manager or Parameter Store, lock down the execution role, add a CI gate, and rotate whatever was exposed. After that, the check stays green on its own.