← FinOpsAid blog

GitHub App installation tokens got 13x longer. Here's the five-minute audit.
GitHub App installation tokens are moving from a fixed 40-character opaque string to a stateless format around 520 characters long. The prefix never changed, which is why the failure is silent, and the per-request header that lets you test both formats is explicitly temporary. If you run a GitHub App, CI automation, or cost tooling, this is a short audit with a narrowing window.
If you own a GitHub App and nothing has broken, this is a five-minute audit with a narrowing window. The failure mode is silent, and the tool GitHub gave you for checking it is temporary.
Installation access tokens used to be exactly 40 characters. The new format is roughly 520. The ghs_ prefix did not change, which is precisely why this one slipped past a lot of teams: every check that looked at the prefix kept passing, and every check that looked at the length stopped.
What actually changed
On April 24, 2026, GitHub published a notice that installation access tokens were moving from a stateful opaque string to a stateless format (GitHub Changelog, April 2026). A staged rollout began April 27, 2026, covering Actions GITHUB_TOKEN and what GitHub calls "first-party featured integrations." A second phase, scheduled for mid-May to late June 2026, was to extend it to all GitHub App installation tokens.
Worth being precise about status, because a lot of secondhand coverage is not. GitHub has published a schedule, not a completion notice. As of September 8, 2026, GitHub's own documentation still says it "began a staged rollout," and no GitHub page announces the rollout as finished. So "our tokens are already stateless" is something to verify rather than assume, which is exactly what the override header below is for.
The shape of the token is the whole story:
| Property |
Classic (stateful) |
Stateless |
| Prefix |
ghs_ |
ghs_ (unchanged) |
| Length |
exactly 40 characters |
~520 characters, varies |
| Structure |
opaque, no dots |
ghs_APPID_JWT, two dots |
| Contents |
a lookup handle |
claims encoded in the token |
That "varies" matters more than the 520 does. GitHub's own wording is that the length "will vary based on the data stored within it," so 520 is a working figure, not a ceiling to code against. The JWT portion is signed by a GitHub-internal issuer and, in GitHub's words, "cannot nor should not be validated by a client app."
Scope: GitHub Enterprise Cloud and Data Residency environments, covering server-to-server tokens including the Actions GITHUB_TOKEN. GitHub Enterprise Server is unaffected. It sits alongside the granular Dependabot alert scope in the September Actions updates, another change that narrowed what an App has to be trusted with.
Total lengths are to scale. Figures from GitHub's April 24, 2026 changelog and the Enterprise Cloud docs; ~520 is GitHub's own approximation and varies with the data encoded in the token.
Why is the failure hard to trace?
A truncated token does not fail where it was created. It fails on the next API call that uses it, as a 401, often hours later and in a different service. The mint succeeded. The write succeeded. The request that used it did not.
That gap is what turns a one-line schema problem into an afternoon. A strict database errors on insert, which is the good case, because the error points at the cause. The quiet failures are worse: a non-strict column that truncates without complaint, a fixed-size buffer, or a log pipeline that clips the field.
Three properties decide whether you are affected, and none of them are about the token's contents:
- Do you persist the token? A column with a fixed width is the most common break.
- Do you validate its shape? A pattern anchored to the old length rejects a token that is still valid.
- Do you pass it through anything with a length limit? Buffers, log fields, and header caps all count.
If the answer to all three is no, you are almost certainly fine. That is worth knowing precisely, because it is a much faster audit than reading your whole auth path.
Our finding: we checked our own GitHub App against those three questions and it needed no change, for reasons worth copying. The token provider in packages/gh/src/auth.ts reads body.token off the mint response as a plain string, caches it in memory, refreshes about a minute before the expires_at it was handed, and never writes it to disk or to the database. There is no length check and no format regex anywhere in that path, and no column anywhere in our schema stores a GitHub installation access token. The property that saved us was not foresight about this change. It was treating the token as opaque and refusing to persist a short-lived credential.
That is the actual lesson, and it generalizes past this one rollout. A credential you never store cannot be truncated by your storage.
How to verify with the override header
GitHub shipped a per-request override header on May 15, 2026 so you can test both formats deliberately instead of waiting to find out (GitHub Changelog, May 2026). Send it on the mint call:
POST /app/installations/{installation_id}/access_tokens
X-GitHub-Stateless-S2S-Token: enabled
enabled forces the stateless format. disabled forces the classic opaque one. Omitting the header gives you standard rollout behaviour. Anything else, including true, false, 1 and 0, is silently ignored, which is its own small trap: a typo in the value looks exactly like a passing test.
Run your integration suite with each value and compare. The point of disabled is not to keep using it. It is to prove that a failure you just saw comes from the format and not from something else you changed.
The header is temporary. GitHub said deprecation would be announced separately, and as of September 8, 2026 no deprecation date has been published. So this is a verification tool on an unknown clock, not a compatibility switch to leave in production.
What to do now
- Today: delete the length assumption rather than updating it. Search your codebase and schema for
40, varchar(40), and any ghs_ pattern match. GitHub's guidance is blunt: "It's recommended that you treat tokens as opaque strings and avoid validating them against hardcoded patterns." Its notice cites ghs_[A-Za-z0-9]{36} as an example of a regex you should not have. If something in your stack genuinely needs a matcher, widen it and drop the upper bound, but the supported answer is to stop matching.
- Today: widen the column before you test. GitHub's guidance is that token columns must fit at least 520 characters. Because the length varies with encoded data, size for growth rather than to the number.
- This week: run the suite with the header both ways.
enabled, then disabled. A pass on enabled is your real answer.
- This week: check the places that are not your code. Self-hosted runner images, secret stores, log scrubbers, and any vendor integration that takes a token as a config value.
- Then remove the header. It is a test instrument and it is going away.
Do NOT:
- Do not validate the JWT. GitHub is explicit that the internal signature is not for clients to verify. Parsing it as a structured credential builds on something you were told not to depend on.
- Do not read the claims and route on them. The token is stateless, but it is still meant to be opaque to you. Anything you infer from its contents is an undocumented dependency on a format that just proved it can change.
- Do not code to 520. It is an approximation that varies by design.
- Do not leave
disabled in production as a way of postponing the work.
Where FinOpsAid fits
FinOpsAid authenticates as a GitHub App with read-only scopes and no personal access tokens, so this rollout was a verification exercise rather than a migration. If you are chasing an auth failure in your own stack, the security page shows access posture across your org, and the sync activity page shows per-connector API call counts and failures, which is where a token problem surfaces as a pattern rather than a one-off 401. Our own read of it is in how to read the sync activity page. Worth being clear on the limits: this is nightly-synced data, not live monitoring, and GitHub's Copilot and billing metrics lag roughly two to three days by design.
Frequently asked questions
Do I need to change anything if my app does not store the token?
Probably not. If you hold the token in memory, treat it as an opaque string, and never validate its length or format, the change is transparent. Verify with the X-GitHub-Stateless-S2S-Token: enabled header rather than assuming, since a length assumption can hide in a dependency or a log pipeline.
What regex should I use for a GitHub App installation token?
None, if you can avoid it. GitHub recommends treating tokens as opaque strings and avoiding validation against hardcoded patterns, and it names ghs_[A-Za-z0-9]{36} as an example of a pattern to remove. A format you validate is a format you have to keep updating.
Did the token prefix or expiry change?
No. The prefix is still ghs_, and GitHub's notice described no change to token lifetime or revocation behaviour. Only the length and internal structure changed, from exactly 40 opaque characters to roughly 520 characters containing an app id and a JWT with two dots.
Sources
If you want to see which GitHub Apps and accounts hold access to your org on one screen, connect your GitHub org — read-only and free during beta — or explore the demo dashboard first.