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:
- It authenticates with long-lived
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYrepo 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. - 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 currentubuntu-latestrunner. 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 --argbuilds the JSON safely. Values with quotes, backslashes, or newlines no longer break the payload (or worse, inject extra fields).- No
payload.jsonwritten 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 textdoes the JSON drilling on the AWS CLI side, nojqpipe needed.- Bash parameter expansion strips the surrounding
"instead oftr -d '"' | tr -d '\\', which used to mangle any value containing a literal quote or backslash. >> "$GITHUB_OUTPUT"replaces the dead::set-outputsyntax. 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.