Baobaobaolin.com
date
entry
006
topic
tooling
rev

Put the API token in SSM Parameter Store, not .env

The problem with a .env file is not that it is insecure. It is that it gets copied. Here is how I change a single DNS record now: the token comes out of an encrypted parameter into a shell variable, the zone id is looked up every time, and closing the terminal ends it — plus why “probably nobody saw it” is never a criterion.

A Cloudflare API token sits in a .env file with permission to write DNS for every zone on the account. The file is in .gitignore, mode 600, never committed. Sounds contained.

Then you retrace where it has been over six months:

  • the project directory was rsynced to a NAS as a backup, and that copy is not encrypted
  • docker build . ships the whole directory to the daemon as build context — if .dockerignore was not kept in step, it is in an image layer
  • the project was zipped and handed to a colleague during a handover; it is still in their downloads folder
  • someone pasted a config file into a chat window to ask a question and the selection ran three lines long

None of those is a security control failing. Files get copied, and copying does not notify you. The problem is not the permission bits on .env. It is that it is a file.

Where it goes instead

I use an AWS SSM Parameter Store SecureString: KMS-encrypted, governed by IAM, audited by CloudTrail, and free at this scale.

Secrets ManagerSSM Parameter Store
Monthly$0.40 per secretfree for standard parameters
API calls$0.05 per 10kfree
Auto rotationyes, via Lambda hooksno, roll your own schedule
FitsDB passwords, anything rotated on a cycleordinary API tokens

If you need automatic rotation, use Secrets Manager. Otherwise Parameter Store is enough. One-time setup:

aws ssm put-parameter \
  --region ap-northeast-1 \
  --name /cloudflare/api-token \
  --type SecureString \
  --value 'cfat_PASTE_HERE' \
  --description "Cloudflare API token: DNS edit, all zones"

Things that are not secret but are annoying to look up — an account id, say — go next to it as a plain String. No reason to bury them in an encrypted parameter.

What each operation looks like

Pull it into a shell variable, never to disk:

CF_TOKEN=$(aws ssm get-parameter \
  --region ap-northeast-1 \
  --name /cloudflare/api-token \
  --with-decryption \
  --query Parameter.Value --output text)

Confirm the token is still alive before doing anything that mutates state:

curl -s -H "Authorization: Bearer $CF_TOKEN" \
  https://api.cloudflare.com/client/v4/user/tokens/verify \
  | jq '.success, .result.status'
# expect true / "active"

Worth the extra call: an expired token and an under-scoped token produce errors that read almost identically, and verifying first removes a whole round of guessing.

Do not store the zone id

This is the part people skip. Look it up by domain name, every time:

DOMAIN="example.com"
CF_ZONE_ID=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN" \
  | jq -r '.result[0].id')

[ -z "$CF_ZONE_ID" ] || [ "$CF_ZONE_ID" = "null" ] && {
  echo "ERROR: zone $DOMAIN is not on this account"; return 1
}

Three reasons:

  1. Adding a domain needs no config change. Hardcode zone ids and every new domain is another entry someone eventually forgets to add.
  2. They drift. Transfer a domain out and back, or move it to another Cloudflare account, and the zone id changes. A stored copy does not raise an error — it quietly points at a zone you have no rights to.
  3. The lookup validates the premise. No result means "this domain is not on this account", which is something you wanted to confirm before editing DNS anyway.

One HTTP round trip buys you an entire category of "the config no longer matches reality" bugs. Good trade.

Rules that keep it off disk

  • Never echo $CF_TOKEN — that puts it in scrollback, and scrollback gets screenshotted
  • Never redirect it to a file, including a "temporary" one
  • In scripts, pass it through an environment variable, never inline as a string — inline values land in the process list, where anyone else on the box can ps for them
  • Close that terminal tab when you are done

Two Cloudflare-specific traps while we are here: verification CNAMEs (DKIM and friends) must be proxied: false or validation never passes; and a token is rate limited to 1200 requests per five minutes, so batch operations need their own sleeps.

What counts as leaked

The criterion is simple: if that string has ever appeared in a file, a git object, a chat window, or an uncleared terminal buffer, it is leaked. You do not need evidence that anyone read it, and you do not get to estimate the odds.

People skip this step because rotation feels expensive. In practice it is: roll the token in the dashboard, then

aws ssm put-parameter \
  --region ap-northeast-1 \
  --name /cloudflare/api-token \
  --type SecureString \
  --value 'cfat_NEW_VALUE' \
  --overwrite

Two minutes. When the fix is that cheap you no longer need to decide whether an exposure "really counted" — that judgement costs more than the rotation and it gets made wrong.

It is the same move as replacing a judgement call with a rule: rules get executed, judgement gets skipped.

The costs

Being honest about the downsides:

  • Every operation adds an AWS API call, a few hundred milliseconds
  • It ties you to AWS. Without working cloud credentials at hand, you cannot even edit DNS
  • An all-zones token scope is convenient, and the blast radius if it leaks is also all zones — pair it with an IP allowlist and an expiry date

The second one genuinely blocks people sometimes. I accept it, because "edit DNS" should not be something you can do offhand. Requiring a valid cloud identity first is a feature, not a defect.

If you remember one thing

A credential's risk scales with its permissions and with how many times it has been copied. It does not scale with how careful you are. Move it off the filesystem into somewhere that requires an identity to read and records every read, and you stop having to remember every place a copy might have landed.

Revision history

  1. First published