# Route53 as a Key Value Store, 2026 Edition

> A modernized take on using Route53 TXT records as a key-value store from GitHub Actions: OIDC auth, no static keys, and a cleaner update flow.

- URL: https://doug.sh/posts/route53-as-a-key-value-store-2026-edition/
- Author: Doug Calobrisi
- Date: 2026-04-18
- Tags: github, actions, aws, route53, oidc


## Five Years Later

Back in 2021 I [wrote about using Route53 Private Hosted Zone TXT records as a key-value store for GitHub Actions](/posts/using-route53-as-a-key-value-store-in-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](/posts/using-route53-as-a-key-value-store-in-github-actions/#creating-a-private-hosted-zone) 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:

```json
{
  "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:

```json
{
  "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

```yaml
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

```yaml
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

```yaml
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](https://docs.github.com/en/actions/reference/workflows-and-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.

