Skip to content

Prefix newly issued access tokens, refresh tokens and personal access tokens - #3163

Merged
jeremy merged 12 commits into
mainfrom
oauth-token-prefixes
Oct 27, 2026
Merged

jeremy merged 12 commits into
mainfrom
oauth-token-prefixes

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3159, the tip of the OAuth train (#2296 → #3080 → #3081 → #3082 → #3160 → #3161 → #3159).

New tokens now start with a prefix that says what kind of token they are:

Kind Prefix
Personal access token fizzy_pat_
OAuth access token fizzy_at_
OAuth refresh token fizzy_rt_

With a prefix, a person, a log scrubber or a secret scanner can tell a Fizzy credential on sight, and which kind it is. The random part is 44 base58 characters (the alphabet has_secure_token uses, drawn through Ruby's SecureRandom.alphanumeric), 257 bits, at least bc3's 32 bytes (rubric TK-1). It was 24 characters, about 140 bits, until 2026-10-17; credentials issued earlier keep working, since lookups match the whole string.

Existing tokens keep working. Tokens issued before this change have no prefix. Every lookup still matches the whole string: Bearer authentication, refresh, revocation and replay detection. A grant issued before this gets prefixed tokens the next time it rotates. A personal access token keeps the token it was issued with until someone revokes it.

How Basecamp and HEY do it

  • Basecamp (bc3) defines its prefixes in Oauth::TokenGenerator::PREFIXES (app/models/oauth/token_generator.rb):

    • bc_at_ for access tokens, including personal access tokens, which are OAuth access tokens issued by the bc-personal-access client
    • bc_rt_ refresh tokens, bc_code_ authorization codes, bc_dc_ device codes
    • bc_cs_ client secrets, but only the ones issued by its rake task (agent client secrets are unprefixed base58(43))
    • bc_iat_ initial access tokens

    The random part is SecureRandom.hex(32) with no checksum, and bc3 stores HMAC digests rather than the raw tokens. It routes requests by prefix: bc_at_ goes to bc3's own OAuth tokens and anything else to the legacy Launchpad tokens.

  • HEY (haystack) doesn't use prefixes. Its OAuth access and refresh tokens are signed MessageVerifier payloads, and its client uid and secret are plain has_secure_token values.

  • Secret scanning: neither app is registered with GitHub's secret-scanning partner program. Neither repo has an alert endpoint or a pattern file for one.

Fizzy uses a separate fizzy_pat_ prefix for personal access tokens, unlike bc3. That keeps the kind visible on sight, and it stays meaningful if personal access tokens are later rebuilt on OAuth (see #3164).

GitHub secret-scanning partner registration: worth pursuing later, not now

  • What it would buy: GitHub would notify us when a Fizzy token is pushed to a public repo, and push protection could block the push in the first place. The likeliest leaks are personal access tokens pasted into fizzy-cli or MCP configs. Support confidential clients with client_secret_post #3082 makes personal access tokens revocable by whoever holds one, so an alert handler could revoke a leaked token immediately.
  • What it would cost:
    • A public endpoint that verifies GitHub's signed alert payloads
    • A pattern for GitHub to register
    • Ongoing ownership of both
  • Caveats:
    • Self-hosted Fizzy issues the same prefixes, so some alerts would be for tokens we never issued. The handler would just find nothing, but it adds noise.
    • There's no checksum, so GitHub can't rule out false positives offline. A CRC suffix, as GitHub's own tokens have, would fix that. It would be a format change to make before registering.
  • Recommendation: worth pursuing once OAuth is out of the dark and personal access token use grows. It isn't pursued here.

Not in this PR

Client secrets carry fizzy_cs_ too (added 2026-10-17 for rubric TK-1, as bc3's rake-issued secrets carry bc_cs_), drawn by the same generator: Identity::AccessToken.generate_token(:client_secret).

Tests

  • Identity::AccessTokenTest: each kind gets its prefix and the base58 body. A grant issued before prefixes rotates into prefixed tokens.
  • OauthFlowTest: the code exchange returns prefixed tokens. An unprefixed grant still authenticates against the API and refreshes into prefixed tokens. An unprefixed personal access token still authenticates.
  • OauthRevocationTest: unprefixed personal and OAuth tokens still revoke.

@jeremy
jeremy force-pushed the oauth-token-prefixes branch from 343886b to ba7d5cb Compare October 6, 2026 10:22
@jeremy
jeremy marked this pull request as ready for review October 6, 2026 10:22
Copilot AI balanced review requested due to automatic review settings October 6, 2026 10:22
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T04:35:08.043276Z 3aa1056 New commits
🔒 Security Review ✅ Completed 2026-10-26T04:36:10.084904Z 3aa1056 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@jeremy
jeremy force-pushed the oauth-token-prefixes branch from ba7d5cb to d1c4bde Compare October 6, 2026 10:28
@jeremy
jeremy force-pushed the oauth-client-sweep branch 2 times, most recently from afc9599 to fc3f2da Compare October 6, 2026 10:32
@jeremy
jeremy force-pushed the oauth-token-prefixes branch from d1c4bde to 6d21161 Compare October 6, 2026 10:32
@jeremy
jeremy force-pushed the oauth-client-sweep branch from fc3f2da to 98e7112 Compare October 6, 2026 10:41
@jeremy
jeremy force-pushed the oauth-token-prefixes branch from 6d21161 to 030f8cc Compare October 6, 2026 10:41
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 98e7112 to 9ec1432 Compare October 6, 2026 10:48
@jeremy
jeremy force-pushed the oauth-token-prefixes branch 2 times, most recently from a446e74 to c4b4c50 Compare October 6, 2026 10:55
@jeremy jeremy mentioned this pull request Oct 6, 2026
4 tasks
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

New PR for Jeremy's decision (d), stacked on #3159.

  • Newly issued tokens are prefixed: fizzy_pat_ for personal access tokens, fizzy_at_ for OAuth access tokens and fizzy_rt_ for OAuth refresh tokens.
  • Existing unprefixed tokens keep working. Lookups match the whole string, and an old grant gets prefixed tokens at its next rotation.
  • How bc3 and haystack do it, and the recommendation on GitHub secret-scanning partner registration (worth it after light-up, with a checksum suffix first; not pursued here), are in the body.

Rebased with the train. CI is green.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: c4b4c507de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jeremy
jeremy force-pushed the oauth-token-prefixes branch from c4b4c50 to f6bd906 Compare October 6, 2026 11:16
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 3684478 to 342dc11 Compare October 6, 2026 11:16
@jeremy
jeremy force-pushed the oauth-token-prefixes branch from f6bd906 to eef7b5c Compare October 6, 2026 11:30
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 342dc11 to 3e41255 Compare October 6, 2026 11:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eef7b5c132

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/models/identity/access_token.rb Outdated
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 3e41255 to 4c1d4d5 Compare October 6, 2026 11:38
@jeremy
jeremy force-pushed the oauth-token-prefixes branch 2 times, most recently from 1d47144 to f2d3acf Compare October 6, 2026 11:48
@jeremy
jeremy force-pushed the oauth-client-sweep branch from 4c1d4d5 to 92776e5 Compare October 6, 2026 11:48
Copilot AI dismissed their stale review, a newer Copilot review was requested October 17, 2026 10:19
Copilot AI previously approved these changes Oct 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The supplied changes preserve whole-token lookups and add compatibility coverage, with no blocking issues identified in static review; tests were not executed.

No open issues 🎉

Review effort: Balanced

… tokens

New tokens start with a prefix that names their kind:

- fizzy_pat_ for personal access tokens
- fizzy_at_ for OAuth access tokens
- fizzy_rt_ for OAuth refresh tokens

A prefix lets a person, a log scrubber or a secret scanner recognize a Fizzy
credential on sight. The random part is unchanged: 24 base58 characters from
generate_unique_secure_token.

Tokens issued before this have no prefix and still work, since every lookup
matches the whole string. A grant issued before this gets prefixed tokens at
its next rotation. Personal access tokens keep the token they were issued
with.

This follows Basecamp, which prefixes the tokens it issues (bc_at_, bc_rt_).
…se58

generate_unique_secure_token calls SecureRandom.base58, an ActiveSupport
extension that only has_secure_token requires. In a lazily loaded process
(the dev server, a console) where this was the first secure-token model
touched, minting or rotating a token could raise NoMethodError. Ruby's own
SecureRandom.alphanumeric with the same base58 alphabet keeps the format and
drops the dependency.
Client secrets now carry fizzy_cs_, as bc3's carry bc_cs_, so secret
scanning can recognize them too. Every newly issued token and secret
has 44 base58 characters after its prefix, 257 bits, where 24 gave
about 140: at least bc3's 32 bytes. Earlier credentials keep working,
since every lookup matches the whole string.
@jeremy
jeremy force-pushed the oauth-token-prefixes branch from 7e22445 to 918f256 Compare October 17, 2026 10:32
@jeremy
jeremy requested a balanced review from Copilot October 17, 2026 10:50
Copilot AI dismissed their stale review, a newer Copilot review was requested October 17, 2026 10:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Credential generation remains unverified on the required Ruby runtime, so human review is needed before approval.

No open issues 🎉

Review effort: Balanced

jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '918f2566ddca90348a5eced35e8bb2da7155febf':
  Prefix client secrets, and draw every new credential from 256 bits
  Draw token bodies through Ruby's SecureRandom, not ActiveSupport's base58
  Prefix newly issued access tokens, refresh tokens and personal access tokens
jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '4cfbbd2ec21b0b4bd7d0e17c16bb8ba86cd4ea4e':
  Authenticate an undigested token outside the primary datacenter without healing it
  Send grants' raw tokens in the challenge pins, which sent none since digests
  Present the read-only grant pin's token from memory, as digests require
  Present and compare the bare-Basic pin's credentials as digests require
  Keep the token form page out of Turbo's cache, so Back fetches a fresh form
  Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too
  Present the superseded-replay pin's refresh token from memory, as digests require
  Count a client secret's randomness as #3163 now draws it
  Fall back to plaintext for prefixed tokens too, and only on an exact match
  Keep a revealed token out of Turbo's snapshot cache
  Point the conformance record at #3192
  Write a healed token's digest to the primary, even when it was found on the replica
  Store tokens and client secrets as digests, reading by digest first
jeremy added a commit that referenced this pull request Oct 17, 2026
* commit '09523ade5f33a3a6e2f933b061fbb18822d98524':
  Advertise the issuer without a trailing slash, as bc3 does
  Authenticate an undigested token outside the primary datacenter without healing it
  Send grants' raw tokens in the challenge pins, which sent none since digests
  Present the read-only grant pin's token from memory, as digests require
  Present and compare the bare-Basic pin's credentials as digests require
  Keep the token form page out of Turbo's cache, so Back fetches a fresh form
  Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too
  Present the superseded-replay pin's refresh token from memory, as digests require
  Count a client secret's randomness as #3163 now draws it
  Fall back to plaintext for prefixed tokens too, and only on an exact match
  Keep a revealed token out of Turbo's snapshot cache
  Point the conformance record at #3192
  Write a healed token's digest to the primary, even when it was found on the replica
  Store tokens and client secrets as digests, reading by digest first
jeremy added a commit that referenced this pull request Oct 18, 2026
* commit '617edb87b':
  Keep identity_access_tokens.refresh_token until it can go with token
  Advertise the issuer without a trailing slash, as bc3 does
  Authenticate an undigested token outside the primary datacenter without healing it
  Send grants' raw tokens in the challenge pins, which sent none since digests
  Present the read-only grant pin's token from memory, as digests require
  Present and compare the bare-Basic pin's credentials as digests require
  Keep the token form page out of Turbo's cache, so Back fetches a fresh form
  Tell Kamal operators to list OAUTH_TOKEN_PEPPER in env.secret too
  Present the superseded-replay pin's refresh token from memory, as digests require
  Count a client secret's randomness as #3163 now draws it
  Fall back to plaintext for prefixed tokens too, and only on an exact match
  Keep a revealed token out of Turbo's snapshot cache
  Point the conformance record at #3192
  Write a healed token's digest to the primary, even when it was found on the replica
  Store tokens and client secrets as digests, reading by digest first
…ing partway

* origin/oauth-client-sweep:
  Make the OAuth migrations safe to run again after stopping partway
* origin/oauth-client-sweep:
  Answer 404 from a dark endpoint however many times it's asked
* tp/oauth-client-sweep: (60 commits)
  Name v0.2.0 in the pin's comment
  Pin fizzy-mcp-server v0.2.0
  Refuse a join code that was reset while it was being redeemed
  Filter presigned storage signatures from logs
  Keep the reviewer's memberships in reach, and print the member audit escaped
  Import an export that lacks a column this database has only just gained
  List the reviewer accounts' members when signing the reviewer out
  Bump thruster from 0.1.26 to 0.1.27 (#3199)
  Give the asset build a placeholder direct-upload credential
  Anchor the stubbed storage host pattern
  Sign direct uploads with a write-only storage credential
  Lock the reviewer's email address, so a code holder can't move its membership out of revocation's reach
  Use Ruby's GC defaults and log job memory (#3238)
  Pin the fizzy_mcp accessory to fizzy-mcp-server v0.1.0 by digest
  Scope webhook dispatch by account as well as board
  Test the reviewer audit against the structured log line production writes
  Revoke on the email alone, and record each use in the request's structured log
  Remove the reviewer's access tokens and passkeys on sign-out, so a code holder can't keep a way back in
  Wire the reviewer sign-in into production from Deploy/Fizzy
  Close the reviewer's live sockets on sign-out, and keep the identity id out of Sentry
  ...
* tp/oauth-client-sweep:
  Bring main's tests up to this branch: Ruby 4 and the access token finder
* tp/oauth-client-sweep:
  Pin the load balancers to kamal-proxy v0.10.2, and say why configure-lb-mcp.sh still checks
  Give the load balancer the real client IP
* tp/oauth-client-sweep:
  Bring main's SQL redaction tests up to bearer lookup by find_honored
  Redact OAuth client statements from logged SQL, now that a client has a secret
* tp/oauth-client-sweep:
  Redact retired refresh tokens from logged SQL
* tp/oauth-client-sweep:
  Filter the code parameter by its whole name, so code_challenge stays readable
* tp/oauth-client-sweep:
  Read a replayed refresh token's grant past the query cache
Base automatically changed from oauth-client-sweep to main October 27, 2026 06:28
@jeremy
jeremy merged commit 6916a4c into main Oct 27, 2026
14 checks passed
@jeremy
jeremy deleted the oauth-token-prefixes branch October 27, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants