Route53 as a Key Value Store, 2026 Edition

Apr 18, 2026 ยท 1023 words ยท 5 minute read

Five Years Later ๐Ÿ”—

Back in 2021 I wrote about using Route53 Private Hosted Zone TXT records as a key-value store for GitHub Actions . The trick is still good in 2026 โ€” Route53 is cheap, durable, globally replicated, and you almost certainly already have an AWS account. I still use it.

But the implementation in that post has aged badly. Two things in particular:

  1. It authenticates with long-lived AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY repo secrets. In 2026, that’s malpractice. GitHub OIDC for AWS shipped in late 2021 โ€” a few weeks after the original post โ€” and there’s no good reason to keep static AWS keys in a GitHub repo anymore.
  2. The Get action ends with echo "::set-output name=value::$VALUE". That workflow command was deprecated in October 2022 and disabled by default in 2023. The original Get action no longer works on a current ubuntu-latest runner. Embarrassing, but here we are.

There are a few smaller polish items too โ€” fragile quote-stripping with tr, a payload.json file written to disk, no escaping if your value happens to contain a ". Let’s fix all of it.

What’s Not Changing ๐Ÿ”—

The trick itself is unchanged: a Route53 Private Hosted Zone named kvstore, one TXT record per key, UPSERT to write, list-resource-record-sets to read. If you don’t have the zone yet, the original post covers creating it.

The Big Upgrade: OIDC ๐Ÿ”—

Instead of putting AWS keys in your repo secrets, GitHub Actions can mint a short-lived OIDC token, hand it to AWS STS, and assume an IAM role you’ve configured. No long-lived credentials anywhere.

This is a one-time AWS setup:

1. Add GitHub as an OIDC provider in your AWS account. URL https://token.actions.githubusercontent.com, audience sts.amazonaws.com. The console has a one-click for this; the CLI is aws iam create-open-id-connect-provider.

2. Create an IAM role (gha-route53-kv) with a trust policy that lets your repo assume it:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
      "StringLike":   { "token.actions.githubusercontent.com:sub": "repo:OWNER/REPO:*" }
    }
  }]
}

Tighten the sub condition to a specific branch (repo:OWNER/REPO:ref:refs/heads/main) or environment if you want.

3. Attach a permissions policy that scopes the role to your KV zone:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "route53:ChangeResourceRecordSets",
      "route53:ListResourceRecordSets"
    ],
    "Resource": "arn:aws:route53:::hostedzone/Z0750343I14B1A1HAP9"
  }]
}

That’s the AWS side. From GitHub, all you need is the role ARN โ€” no secret to rotate, ever.

Composite Action: Set ๐Ÿ”—

name: 'Set Route53 TXT Record'
inputs:
  key:
    description: 'FQDN of Key'
    required: true
  value:
    description: 'Value to set'
    required: true
  hosted-zone-id:
    description: 'Route53 Hosted Zone ID'
    required: true
runs:
  using: "composite"
  steps:
    - name: Set Record
      shell: bash
      env:
        KEY: ${{ inputs.key }}
        VALUE: ${{ inputs.value }}
        ZONE_ID: ${{ inputs.hosted-zone-id }}
      run: |
        PAYLOAD=$(jq -nc --arg name "$KEY" --arg val "$VALUE" '{
          Changes: [{
            Action: "UPSERT",
            ResourceRecordSet: {
              Name: $name,
              Type: "TXT",
              TTL: 30,
              ResourceRecords: [{ Value: ("\"" + $val + "\"") }]
            }
          }]
        }')
        aws route53 change-resource-record-sets \
          --hosted-zone-id "$ZONE_ID" \
          --change-batch "$PAYLOAD"

What changed from 2021:

  • jq -nc --arg builds the JSON safely. Values with quotes, backslashes, or newlines no longer break the payload (or worse, inject extra fields).
  • No payload.json written to disk.
  • Inputs are passed via env: rather than interpolated directly into the shell script โ€” the 2021 version was a script-injection vector.

Composite Action: Get ๐Ÿ”—

name: 'Get Route53 TXT Record'
inputs:
  key:
    description: 'FQDN of Key'
    required: true
  hosted-zone-id:
    description: 'Route53 Hosted Zone ID'
    required: true
outputs:
  value:
    description: 'Value of key'
    value: ${{ steps.get-record.outputs.value }}
runs:
  using: "composite"
  steps:
    - name: Get Record
      id: get-record
      shell: bash
      env:
        KEY: ${{ inputs.key }}
        ZONE_ID: ${{ inputs.hosted-zone-id }}
      run: |
        RAW=$(aws route53 list-resource-record-sets \
          --hosted-zone-id "$ZONE_ID" \
          --query "ResourceRecordSets[?Name=='${KEY}.'].ResourceRecords[0].Value | [0]" \
          --output text)
        VALUE=${RAW#\"}
        VALUE=${VALUE%\"}
        echo "value=$VALUE" >> "$GITHUB_OUTPUT"

What changed from 2021:

  • --query ... --output text does the JSON drilling on the AWS CLI side, no jq pipe needed.
  • Bash parameter expansion strips the surrounding " instead of tr -d '"' | tr -d '\\', which used to mangle any value containing a literal quote or backslash.
  • >> "$GITHUB_OUTPUT" replaces the dead ::set-output syntax. This is the line that makes the action work again.

Putting It Together ๐Ÿ”—

name: Route53 KV Store Example
on: [workflow_dispatch]

permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/gha-route53-kv
          aws-region: us-east-1

      - uses: ./actions/set
        with:
          key: testkey.kvstore
          value: "hello world"
          hosted-zone-id: Z0750343I14B1A1HAP9

      - uses: ./actions/get
        id: get-testkey
        with:
          key: testkey.kvstore
          hosted-zone-id: Z0750343I14B1A1HAP9

      - run: echo "${{ steps.get-testkey.outputs.value }}"

Notice what’s missing: no AWS_ACCESS_KEY_ID, no AWS_SECRET_ACCESS_KEY, no secrets at all. The id-token: write permission is the bit people forget โ€” without it, configure-aws-credentials can’t request a token, and you’ll get a confusing 403 from STS.

A Note on TXT Record Limits ๐Ÿ”—

The 2021 post didn’t mention this and it tripped me up later: a single TXT string is capped at 255 characters. You can include multiple strings in one record and they get concatenated, which gets you to a practical ceiling around 64 KB โ€” but if you’re storing anything longer than a short token, ID, or version string, chunking it is your problem to solve. For the “stash a string between workflow runs” use case it’s a non-issue.

What About GitHub Actions Variables? ๐Ÿ”—

GitHub Actions Variables launched in January 2023, well after the original post. Read as ${{ vars.MY_KEY }}, set with gh variable set. No cloud account, no IAM, free โ€” for a lot of “stash a string between workflow runs” use cases, they’re the obvious answer.

The catch: writing them from a workflow needs a PAT or fine-grained token with actions: write. The default GITHUB_TOKEN can’t do it. So the canonical KV pattern โ€” workflow A computes a value, workflow B reads it โ€” forces you to provision a separate token and store that in repo secrets. Which puts you right back where the OIDC upgrade above was getting you out of: a long-lived credential sitting in your repo.

If you’re happy managing a PAT, Variables are great. If you’ve already done the OIDC setup for something else in AWS, Route53 stays cleaner.