SevyDevy

Guide · Backend

GitHub's November 2026 App Token Cutoff Explained: Treat Tokens as Opaque

13 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The GitHub App installation token format changed in 2026. Installation tokens, the one-hour passwords a GitHub App uses, are now ghs_ plus the app's ID plus a JSON Web Token (JWT). A JWT is a string GitHub signs so it can check it later. Tokens are now about 520 characters, not 40.
  • The long format is already the default. GitHub's 2 October 2026 changelog says the staged rollout, begun on 27 April, is complete. Permissions, repository access and the one-hour expiry did not change.
  • 30 November 2026 ends only the override: X-GitHub-Stateless-S2S-Token, a temporary header sent with the token request that still lets apps ask for short tokens. After that date GitHub ignores it.
  • Treat every token as opaque, meaning you store and send it but never inspect it. GitHub says client apps cannot and should not validate the JWT, and must not depend on its contents.
  • Beginners can stop after the section on what breaks. Engineers then get tested results: old log-masking rules that hide none or part of a token, a logging fix that masks logged error tracebacks, and commands to check the fix.

What goes wrong when your code assumes a 40-character GitHub token?

Things break in odd places: database writes fail, validation checks reject good tokens and logs print secrets in full. The cause is the new GitHub App installation token format. GitHub made it the default for new tokens, and tokens grew from 40 characters to about 520.

Picture a common setup. A small team runs a GitHub App that comments on pull requests, or proposed code changes, when a dependency (a library the code relies on) has a risky licence. The app saves each token in PostgreSQL, a popular open-source database, in a column declared VARCHAR(40): text of at most 40 characters. The setup is made up, but the problem is real.

When GitHub's staged rollout reached the app in June, token writes started failing. The quick fix was a request header that asks GitHub for old-style tokens. That header stops working on 30 November 2026, so the team needs a real fix.

Which words do you need before reading about installation tokens?

You need five terms: GitHub App, installation, installation access token, JWT and opaque. Stateful and stateless get their own section below.

  • GitHub App: an integration that acts on repositories with its own identity and permissions, like the review bot.
  • Installation: one organisation or account that has added the app.
  • Installation access token: a password the app gets from GitHub to act inside one installation. It expires after one hour and starts with ghs_.
  • JWT: a JSON Web Token. A signed JWT is three encoded parts joined by two dots, and the last part is a signature, or a similar check value, that proves who issued it.
  • Opaque: you store and send the value, but never look inside it or check its shape.

The token travels in the Authorization header, the part of an HTTP request that carries credentials. S2S in the header name means server-to-server: the app acting as itself for an installation, not on behalf of a person.

What is the new GitHub App installation token format in 2026?

The new GitHub App installation token format is ghs_APPID_JWT: the old ghs_ prefix, the app's ID, an underscore, then a JWT. A GitHub-internal issuer, a GitHub service that creates and signs tokens, signs that JWT. GitHub puts the length at about 520 characters and says it varies.

GitHub's 2 October changelog lists what did not change: token permissions, repository scoping, the one-hour expiry and the REST endpoint. That is the web address apps call to create tokens through GitHub's API, the interface programs use to talk to GitHub. Tokens minted before the switch keep working until they expire.

GitHub's April notice and 15 May changelog name GitHub Enterprise Cloud and Data Residency, GitHub's hosted offerings, as in scope. The April notice also names the GITHUB_TOKEN, the token that GitHub Actions, GitHub's built-in automation service, gives each workflow run. The 2 October changelog covers all newly minted installation tokens.

Only GitHub Enterprise Server, the version companies host themselves, is named as unaffected. As of 19 October 2026, its docs pages still carry the same rollout note, so check your version's release notes.

Old and new GitHub App installation tokens, from GitHub's changelogs and REST docs
PropertyOld stateful tokenNew stateless token
Prefixghs_ghs_ (unchanged)
Length40 charactersAbout 520, varies with contents
ShapeShort opaque string, no dotsghs_APPID_JWT, contains two dots
GitHub's own termStateful (classic opaque)Stateless (JWT format)
ExpiryOne hourOne hour
Can your app read inside it?Nothing to readNo: GitHub says do not validate or depend on it

One detail is still loose. As of 19 October 2026, GitHub has published no maximum length, only about 520 characters and a request for room for at least 520. Size storage with headroom, and do not treat 520 as a ceiling.

Stateful vs stateless tokens: what is the difference?

A stateful token is a reference to a record the issuer keeps. A stateless token carries signed facts, so the issuer can check it with a key instead of a lookup. GitHub uses both words: stateful for the old format, stateless for the new one.

The idea is older than GitHub's change. The OAuth 2.0 standard, RFC 6749, says an access token may be an identifier used to fetch stored details. It may also contain those details in a verifiable way. The same section says the string is usually opaque to the client.

Think of a railway cloakroom slip and a stamped gate pass. The cloakroom slip is the stateful token: it means nothing until the clerk finds the matching shelf. The gate pass is the stateless token: the guard checks the stamp, not a register.

Where the picture breaks: you could read a printed gate pass, but you should not read GitHub's token. GitHub says a client app cannot validate its signature, and GitHub can change what the token carries.

Two general ways a server checks a token

A general picture only. Notice that the client sends the same header either way. GitHub has not published how it checks tokens internally, and your app never needs to know.

Diagram connections:

  • Review bot to API server (Authorization header)
  • API server to Which kind of token?
  • Which kind of token? to Token record store (Short opaque string)
  • Which kind of token? to Signature check (Long signed token)
  • Token record store to Request allowed
  • Signature check to Request allowed
  • Review bot: Sends the ghs_ token in the Authorization header
  • API server: Receives the token on every request
  • Which kind of token?: Decided by the issuer, not by the client
  • Token record store: Stateful: look up the stored record
  • Signature check: Stateless: verify the issuer's signature with a key
  • Request allowed: Same permissions and expiry either way

GitHub's stated reason is speed and reliability. The 2 October changelog says the format makes token issuance and validation faster and improves the reliability of the GitHub API. The April notice says the JWT holds details such as the target installation and the app.

Why must you treat GitHub tokens as opaque strings?

Because GitHub owns what is inside and has told you not to rely on it. The April notice says the JWT is signed by a GitHub-internal issuer and cannot and should not be validated by a client app. It adds that client apps must not depend on its contents.

You also do not need to look. The response that creates the token already includes expires_at, the expiry time, and the permissions granted. Read those fields and pass the token along untouched.

Opaque also means not assuming the new shape. GitHub's 15 May changelog asks you to test with the classic format too, so your app degrades gracefully if stateless tokens are temporarily unavailable. Code that requires dots is as fragile as code that requires 40 characters.

Which systems break when GitHub App tokens grow to about 520 characters?

Anything that stores, checks, forwards or prints a token with the old shape in mind. GitHub's 2 October changelog lists four places to look, and the review bot had a problem in each.

Storage: columns, secret stores and environment variables

What a 40-character column does with a longer token depends on the database. We ran it on PostgreSQL 16.15 and SQLite 3.45.1, and took MySQL's behaviour from its 8.4 manual. An explicit cast is code that converts a value to a type, and strict SQL mode is a MySQL setting that turns bad data into errors.

What a VARCHAR(40) column does with a token of about 520 characters
Database and settingWhat happensHow we know
PostgreSQL 16.15, normal insertRejected: value too long for type character varying(40)Ran it
PostgreSQL 16.15, explicit cast to varchar(40)Silently cut to 40 charactersRan it; the PostgreSQL manual agrees
MySQL 8.4, strict SQL mode onRejected with an errorMySQL 8.4 manual
MySQL 8.4, strict SQL mode offCut to fit, with only a warningMySQL 8.4 manual
SQLite 3.45.1Stores the whole token; the 40 is not enforcedRan it

A cut token is the worst case. The write succeeds, but the stored string is no longer the token GitHub issued, so the failure shows up later as a rejected API call. GitHub asks that token columns hold at least 520 characters, and the same goes for secret stores and environment variables with a small limit. Secret stores are vaults for passwords, and environment variables are settings passed to a program at start.

Validation: regexes that expect the old pattern

A regex, or regular expression, is a text pattern. Some apps checked tokens against ghs_[A-Za-z0-9]{36} before saving them. GitHub's April notice names that exact pattern as one to remove. The new token has dots and extra underscores, so it never matches.

Proxies, gateways and middleware

Proxies, gateways and middleware are servers or code layers that sit between your app and GitHub and pass requests on. GitHub warns that some truncate or reject long Authorization headers.

Common defaults are far larger than 520 characters. For example, nginx accepts a request header field up to its 8k (8 kilobyte) buffer by default. Limits someone set by hand are what bite, so search proxy configs for them.

Logs, redaction and secret scanners

Redaction means masking secrets before a line is written to a log. A rule built on the old pattern does not match the new token, so the token is printed. A secret scanner, a tool that checks code for passwords before each commit, has the same blind spot.

Where the review bot's token path breaks

Follow the arrows: every box after GitHub held an assumption about the old 40-character token, and each needs a fix before 30 November.

Diagram connections:

  • GitHub token endpoint to Token refresher (New token)
  • Token refresher to PostgreSQL token cache (Save for one hour)
  • PostgreSQL token cache to Outbound gateway (Read for each API call)
  • Outbound gateway to Application logs (Errors written)
  • Application logs to Pre-commit secret scanner (Pasted into code by mistake)
  • GitHub token endpoint: Returns a ghs_ token of about 520 characters
  • Token refresher: Old regex check rejects the valid new token
  • PostgreSQL token cache: VARCHAR(40) column refuses the write
  • Outbound gateway: A hand-set header-size rule can block calls
  • Application logs: Old redaction rule misses the token
  • Pre-commit secret scanner: Old pattern cannot spot a leaked new token

If you are new to this, you can stop here. You know what changed, why tokens must stay opaque and where code breaks. The rest is for engineers making the fix: redaction rules tested line by line, the header deadline and a checklist with expected output.

How do you fix log redaction and secret scanning for the new GitHub tokens?

Use a pattern that finds both formats, and use it only to find and mask tokens, never to judge them. GitHub's 15 May changelog recommends ghs_[A-Za-z0-9\.\-_]{36,} for matching both new and current format tokens.

Finding a token in a log line is detection, and a loose pattern is right there. Rejecting a token that fails a pattern is validation. GitHub's April notice recommends against validating tokens with hardcoded patterns. Its 15 May checklist offers this regex for any check you keep, but we use it only to find tokens.

Old rules leave most of the token in the log

Pause and predict. Some apps redacted with ghs_\w{36}, where \w means a letter, digit or underscore. How much of a new token does that rule hide?

We tested four rules on Python 3.13.16. The new token was synthetic: ghs_, an app ID and a JWT we signed with our own key, 524 characters long. It has the same shape as GitHub's, not GitHub's contents.

Four redaction rules run on one log line holding a synthetic 524-character token
RuleMatches an old 40-character token?Token characters left in the log
ghs_[A-Za-z0-9]{36}YesAll 524: no match at all
ghs_\w{36}Yes484: only the first 40 are masked
ghs_[A-Za-z0-9_]{36,255}Yes472: masking stops at the first dot
ghs_[A-Za-z0-9\.\-_]{36,} (GitHub's)Yes0

What just happened? Two old rules mask a prefix and print the rest. A quick test can even look like success, because the line does contain the mask. A public issue in GitHub's own gh-aw-firewall repository flags its ghs_ masking patterns as broken by the new format.

Mask the finished log line, not one logger

A common first try is a logging.Filter attached to your app's logger. We tested that on Python 3.13.16 and it leaked the full token twice. It missed records from child loggers, such as review_bot.refresher under review_bot, and it missed exception tracebacks.

The child logger leak is documented: Python's logging manual says a logger's filter does not apply to events from descendant loggers. The safer place is the formatter. A handler sends log lines to a destination, such as the console or a file, and its formatter builds each final line.

python
import logging
import re

# GitHub's published pattern (15 May 2026): matches old and new ghs_ tokens
GHS_TOKEN = re.compile(r"ghs_[A-Za-z0-9\.\-_]{36,}")


class RedactingFormatter(logging.Formatter):
    def format(self, record):
        # Build the full line first (message, args, traceback), then mask it
        return GHS_TOKEN.sub("ghs_REDACTED", super().format(record))


handler = logging.StreamHandler()
handler.setFormatter(RedactingFormatter("%(name)s %(levelname)s %(message)s"))
logging.basicConfig(handlers=[handler], level=logging.INFO)

log = logging.getLogger("review_bot.refresher")
log.warning("retrying with %s", "ghs_123456_aaaa.bbbb.cccc_dddd_eeee_ffff_gggg")

Run it and you should see one line: review_bot.refresher WARNING retrying with ghs_REDACTED. Because the formatter masks the finished line, it also covers arguments, other modules and tracebacks you log with log.exception. We checked all three on Python 3.13.16. Uncaught exceptions and print calls skip logging, so they stay unmasked.

Two notes for production. Set this formatter on every handler that writes logs, because a handler with a plain formatter prints the raw line. The pattern also swallows a full stop right after a token, which is harmless for masking.

Give your pre-commit secret scanner the same pattern, so a leaked new token is caught before it reaches the repository.

What happens to GitHub's X-GitHub-Stateless-S2S-Token override header on 30 November 2026?

GitHub stops honouring it. The 2 October changelog says the header will be deprecated on 30 November 2026, and after that all eligible apps always receive stateless tokens. As of 19 October 2026, that post does not define eligible.

GitHub added the header on 15 May 2026 for testing. You send it with the POST request to /app/installations/INSTALLATION_ID/access_tokens that creates an installation token. The value enabled asks for a stateless token, and disabled asks for the old stateful one.

Any other value, such as true or 1, is silently ignored and gets the normal rollout behaviour. Since GitHub reported the rollout complete on 2 October, normal behaviour now means a stateless token. When GitHub added the header in May, it gave no end date. The 2 October changelog is where GitHub states 30 November.

Which token the review bot gets for one request

Read it as of 19 October 2026. After 30 November the disabled branch disappears, so every path ends at the long stateless token.

Diagram connections:

  • Token request to Header value?
  • Header value? to Stateless ghs_ token (enabled, no header or other)
  • Header value? to Before 30 November 2026? (disabled)
  • Before 30 November 2026? to Stateful token (Yes)
  • Before 30 November 2026? to Stateless ghs_ token (No: header ignored)
  • Token request: Bot calls the installation token endpoint
  • Header value?: X-GitHub-Stateless-S2S-Token on this request
  • Before 30 November 2026?: The day GitHub stops honouring the header
  • Stateful token: Short opaque string, no dots
  • Stateless ghs_ token: About 520 characters, two dots

How do you prepare your GitHub App before 30 November 2026?

Find every place a token is stored, checked, forwarded or printed, fix it, test both formats, then delete the header. Here is the review bot's plan, with a check for the steps that change data.

  1. Search the code

    Search for ghs_, the number 40, VARCHAR(40), {36} and X-GitHub-Stateless-S2S-Token. Include proxy, log shipper and secret scanner config.

  2. Widen storage

    Run ALTER TABLE install_tokens ALTER COLUMN token TYPE text. Check it with psql, PostgreSQL's command-line tool: \d install_tokens should show the type text for the token column. Check secret store and environment variable limits too.

  3. Delete validation regexes

    Remove checks like ghs_[A-Za-z0-9]{36}. GitHub's REST docs say an expired token gets 401 Unauthorized, the HTTP error code for a rejected credential. Let the API be the judge.

  4. Fix the edges

    Raise any hand-set proxy or gateway header limits. Switch log redaction and secret scanners to GitHub's pattern for both formats.

  5. Test both formats in staging

    In staging, a test copy of production, run the full bot with the header set to enabled, then disabled, before 30 November. Watch for 401 errors, failed writes and unmasked tokens.

  6. Check what was stored

    After a fresh token is saved, run SELECT max(length(token)) FROM install_tokens. Expect a number near 520 characters, not 40. Use this as a one-off check, never as code that gates tokens.

  7. Remove the header

    Delete the override header from production code before 30 November 2026, as GitHub asks.

After the change, the token refresher stores whatever GitHub returns and never inspects it. Here db is a psycopg 3 connection, the PostgreSQL driver for Python, and your app commits as it normally does.

python
import requests


def refresh_install_token(api_base, app_jwt, installation_id, db):
    resp = requests.post(
        f"{api_base}/app/installations/{installation_id}/access_tokens",
        headers={
            "Authorization": f"Bearer {app_jwt}",
            "Accept": "application/vnd.github+json",
        },  # no X-GitHub-Stateless-S2S-Token header any more
        timeout=10,
    )
    resp.raise_for_status()
    body = resp.json()
    token = body["token"]  # opaque: never split, decode or length-check it
    db.execute(
        "UPDATE install_tokens SET token = %s, expires_at = %s WHERE installation_id = %s",
        (token, body["expires_at"], installation_id),
    )
    return token

We ran this function with requests 2.32.5 and psycopg 3.2.10 against a local stand-in for the token endpoint and a PostgreSQL 16.15 table. It sent no override header and stored a 524-character synthetic token unchanged. The UPDATE assumes each installation already has a row; with none, PostgreSQL updates nothing and raises no error.

Pointed at a VARCHAR(40) column, the same call failed with the error value too long for type character varying(40). That is the failure the team saw in June.

How do you explain the GitHub token format change in a system design interview?

Frame it as a story about token design and trusting contracts, not GitHub internals.

  1. State the change

    GitHub installation tokens moved from 40-character opaque strings to stateless ghs_ tokens of about 520 characters. GitHub reported the rollout complete on 2 October 2026.

  2. Contrast stateful and stateless

    Stateful tokens point to a record the issuer stores. Stateless tokens carry signed facts that the issuer checks with a key.

  3. Name the contract

    Clients can rely on the API response and its expires_at field, not on the token's length or inner layout.

  4. List what breaks

    Fixed-width columns, validation regexes, hand-set proxy header limits, and redaction or scanning rules tied to the old pattern.

  5. Give the fix and the date

    Store tokens as opaque text, detect them with a loose pattern, test both formats and drop the override header before 30 November 2026.

Questions people ask

How long is a GitHub App installation token now?
About 520 characters, as of 19 October 2026, and GitHub says the exact length varies. Old installation tokens were 40 characters. GitHub has not published a maximum, so plan for at least 520 and prefer a column type with no small limit, such as text in PostgreSQL.
Can I decode the JWT inside a ghs_ token to read its expiry?
No, and you do not need to. GitHub says client apps cannot and should not validate the JWT, and must not depend on its contents. The API response that creates the token already includes an expires_at field, so read the expiry from there.
Does this change affect GitHub Enterprise Server?
Not according to GitHub's April and May notices, as of 19 October 2026. GitHub Enterprise Server is the version companies run on their own servers. Its docs pages still show the rollout note, so check your version's release notes. GitHub's notices name GitHub Enterprise Cloud and Data Residency as in scope. An app that supports both should expect short and long tokens side by side.
Does the GITHUB_TOKEN in GitHub Actions use the new format too?
Yes. GitHub's April 2026 notice lists the Actions GITHUB_TOKEN in scope. GitHub planned it for the first stage of the rollout, from 27 April. Scripts that store, log or length-check it need the same review.
Do personal access tokens and other GitHub tokens change?
Not as part of this change. GitHub's April notice says the other token prefixes are unchanged. As of 19 October 2026, GitHub says format changes for some user-to-server tokens will be detailed later, so keep all token code opaque.
Should my app still handle short tokens after the switch?
Yes. GitHub's 15 May changelog asks you to confirm your app works with the classic format, so it degrades gracefully if stateless tokens are temporarily unavailable. Tokens minted before a switch also stay valid until they expire. Code that accepts any opaque string handles both.
Is the regex ghs_[A-Za-z0-9\.\-_]{36,} safe for validating tokens?
Use it for finding tokens, not for validating them. GitHub's 15 May 2026 changelog recommends it for matching both formats, which suits log redaction and secret scanning. The same post offers it for any validation check you keep, but the April notice advises against hardcoded pattern checks. To learn whether a token works, call the API. An expired token gets 401 Unauthorized.

Sources and further reading

Practise this with Lala

Practise