🧭 How toSoftwareIntermediate✨ AI-assisted

How to Prep for Stateless GitHub App Installation Tokens

WittyTech Daily··4 min read
#github-apps#tokens#secrets#migration

Your GitHub App's installation tokens are about to be 13 times longer, and somewhere in your stack a VARCHAR(40) is waiting to ruin your Monday. By the end of this guide you'll have checked storage, proxies, redaction and validation for stateless GitHub App tokens, and removed the temporary header before it stops working on November 30, 2026.

What changes with stateless GitHub App tokens

Per GitHub's changelog, new installation tokens use a stateless ghs_APPID_JWT format of about 520 characters, up from 40. The rollout began April 27, 2026 and is now complete. Permissions, repository scoping, the one-hour expiry and the installation access token endpoint are unchanged. Tokens minted earlier keep working until they expire.

So nothing breaks on GitHub's side. It breaks on yours, wherever something assumed 40 characters.

Step 1: Mint both token formats

The temporary X-GitHub-Stateless-S2S-Token header takes enabled or disabled on POST /app/installations/{id}/access_tokens, the only endpoint that supports it, according to the May changelog. Mint one of each and compare:

export APP_JWT="<your-app-jwt>"
export INSTALLATION_ID="<your-installation-id>"

for mode in enabled disabled; do
  token=$(curl -s --request POST \
    --url "https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens" \
    --header "Accept: application/vnd.github+json" \
    --header "Authorization: Bearer ${APP_JWT}" \
    --header "X-GitHub-Api-Version: 2026-03-10" \
    --header "X-GitHub-Stateless-S2S-Token: ${mode}" | jq -r '.token')
  dots=$(printf '%s' "$token" | tr -cd '.' | wc -c)
  echo "${mode}: ${#token} chars, ${dots} dots"
done

Expect about 520 characters and two dots for enabled. The disabled token is the old short, dotless format, if GitHub still honors it for your app. The script prints lengths only, because tokens in CI logs are how bad weeks begin.

Step 2: Widen token storage

Check every database column, secret store field and environment variable that holds an installation token. GitHub says storage must accept at least 520 characters. I found no documented maximum, so use an unbounded type or generous headroom.

-- PostgreSQL
ALTER TABLE github_installation_tokens
  ALTER COLUMN token TYPE text;

-- MySQL
ALTER TABLE github_installation_tokens
  MODIFY token VARCHAR(2048) NOT NULL;

Run it against a copy first. Altering a column type can lock a busy table.

Step 3: Test proxy and header limits

Send a fake 530-character token through every hop between your service and whatever receives it: gateway, ingress, service mesh, WAF.

seg() { head -c "$1" /dev/zero | tr '\0' 'a'; }
FAKE="ghs_123456_$(seg 200).$(seg 200).$(seg 120)"
echo "${#FAKE} chars"

curl -s -o /dev/null -w '%{http_code}' \
  --header "Authorization: Bearer ${FAKE}" \
  "https://<your-gateway-host>/<any-authenticated-route>"

You want a response from your own app, even an auth failure. A 400, 431 or 502 from a hop in between means that hop has a header limit to raise.

Step 4: Update log redaction for stateless GitHub App tokens

A JWT contains dots. A rule written for [A-Za-z0-9_] stops at the first dot and leaves part of the token in your logs, and the old 36-character rule matches nothing at all. GitHub suggests ghs_[A-Za-z0-9\.\-_]{36,}, which matches both formats:

import re

OLD = re.compile(r"ghs_[A-Za-z0-9]{36}")
NEW = re.compile(r"ghs_[A-Za-z0-9\.\-_]{36,}")

classic = "ghs_" + "A" * 36
stateless = "ghs_123456_" + ".".join(["a" * 170] * 3)

def redact(text: str) -> str:
    return NEW.sub("ghs_[REDACTED]", text)

assert OLD.search(stateless) is None  # the old rule misses the new format entirely
assert redact(f"token={classic}") == "token=ghs_[REDACTED]"
assert redact(f"token={stateless}") == "token=ghs_[REDACTED]"

Apply the same pattern in log shippers, error trackers and CI secret masks.

Step 5: Treat the token as opaque

Delete length and pattern checks. GitHub's guidance is to treat ghs_ tokens as opaque strings, so don't decode the JWT either.

# before: rejects every stateless token
if len(token) != 40:
    raise ValueError("unexpected installation token")

# after: no shape checks, let the GitHub API accept or reject it
headers = {"Authorization": f"Bearer {token}"}

# tests only: tell the formats apart by counting dots after the prefix
is_stateless = token.removeprefix("ghs_").count(".") == 2

Step 6: Remove the X-GitHub-Stateless-S2S-Token header

After November 30, 2026 GitHub stops honoring the header and eligible apps always get stateless tokens. Find every use, run your tests against both formats, then delete the line.

grep -rni "X-GitHub-Stateless-S2S-Token" .
import requests

resp = requests.post(
    f"https://api.github.com/app/installations/{installation_id}/access_tokens",
    headers={
        "Accept": "application/vnd.github+json",
        "Authorization": f"Bearer {app_jwt}",
        "X-GitHub-Api-Version": "2026-03-10",
        # delete the next line once both formats pass your tests
        "X-GitHub-Stateless-S2S-Token": "enabled",
    },
    timeout=10,
)
resp.raise_for_status()
token = resp.json()["token"]

Gotchas

  • Only enabled and disabled count. Values like true or 1 are silently ignored and you get normal rollout behavior, so a test with true tests nothing.
  • The header applies to GitHub Enterprise Cloud and Data Residency, not GitHub Enterprise Server.
  • Tokens minted before the change live for up to an hour, so caches and queues can hold both formats at once.
  • The May changelog says later rollouts include the Actions GITHUB_TOKEN, so check any workflow that forwards it to another system.

If your agents or bots call GitHub through an App, the token passes through queues, secret stores and logs, and a field sized for 40 characters is the kind of failure that only shows up in production.

← More in Software