Skip to content

Enforce token type separation between access and refresh tokens - #259

Merged
jalexw merged 5 commits into
mainfrom
claude/fix-token-type-confusion
Oct 10, 2026
Merged

jalexw merged 5 commits into
mainfrom
claude/fix-token-type-confusion

Conversation

@jalexw

@jalexw jalexw commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Summary

This change adds cryptographic enforcement of token type separation between access and refresh tokens. Access and refresh tokens minted for the auth server's own audience share the same aud, iss, keyset, and subject, making them otherwise indistinguishable. Without type enforcement, a refresh token could be presented as an access token (gaining first-party API access) or an access token could be redeemed as a refresh token (gaining a longer lifetime).

Key Changes

  • JWT signature verification (verify_signature.ts): Added validation that the type claim in the signature payload matches the expected token type. This is the primary enforcement mechanism.

  • JWT decoding (decode.ts): Added early validation of the outer payload's type claim (when present) before signature verification. This provides fast rejection of mis-presented tokens and supports legacy tokens minted before the outer claim existed.

  • JWT generation (generate.ts): Now includes the type claim in the outer JWE payload, mirroring the inner signature's type for early validation.

  • JWT payload schema (payload_data.ts): Updated to include the optional type field in the outer payload schema with documentation explaining its purpose and backward compatibility.

  • Route guard factory (route-guard-factory.test.ts): Added comprehensive tests verifying that:

    • An admin's refresh token presented as an access token is rejected for both authenticated and admin guard types
    • An access token presented as a refresh token is rejected
    • Genuine tokens continue to work as expected
  • Unit tests (token-type-separation.test.ts): Added 302 lines of unit tests covering:

    • Token decoding with correct types
    • Rejection of mismatched token types in both directions
    • Outer type claim validation with mismatch detection
    • Legacy token support (tokens without outer type claim)
    • Signature type claim enforcement
  • E2E tests (TokenTypeSeparation.cy.ts): Added 173 lines of Cypress tests verifying the API never confuses token types:

    • Refresh token presented as Bearer access token returns 401
    • Access token presented to refresh grant returns invalid_grant
    • Control tests confirm genuine tokens work as expected
  • Version bumps: Incremented patch versions across affected packages:

    • @schemavaults/jwt: 0.14.2 → 0.14.3
    • @schemavaults/auth-server-sdk: 0.32.5 → 0.32.6
    • @schemavaults/trpc-backend-init: 0.9.76 → 0.9.77
    • @schemavaults/auth-server: 0.55.2 → 0.55.3
    • @schemavaults/e2e-auth-tests: 0.12.6 → 0.12.7

Implementation Details

  • The signature's type claim is the authority; the outer payload's type (added in this version) provides early rejection before cryptographic verification.
  • Backward compatibility is maintained: tokens minted before the outer claim existed (lacking the type field) fall through to signature verification alone.
  • Both directions are enforced: a refresh token cannot act as an access token, and an access token cannot be redeemed as a refresh token.

https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k

…rver:0.55.3, e2e-auth-tests:0.12.7 - enforce access/refresh token type separation

A platform token is a JWE whose payload carries an inner signed JWT. signJWT
wrote the token's `type` ("access"/"refresh") into that signature, but
verifyJWTSignature never checked it and the outer JWE carried no `type` at
all. For the auth server's own audience, access and refresh tokens share
`aud`, `iss`, keyset and subject, so nothing distinguished them: a
third-party app's refresh token worked as a first-party bearer access token
(privilege escalation, admin routes included), and an auth-server access
token could be redeemed at the refresh grant for a two-week refresh token.

Fix (no token-format break, no token invalidation):
- verify_signature.ts: the signature's `type` claim must equal the type
  being verified for (root cause).
- generate.ts / payload_data.ts: mint `type` into the outer JWE payload too
  (optional in the schema so tokens minted before this stay decodable until
  they expire).
- decode.ts: refuse an outer-type mismatch early.

Regression tests at the jwt layer, the server SDK route-guard layer, and the
E2E api_contract suite cover both confusion directions plus legacy tokens
with no outer type claim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
@jalexw jalexw self-assigned this Oct 10, 2026
…rver:0.55.4 - drop the outer-JWE type claim, keep signature-only enforcement

The first commit also minted a `type` claim into the outer JWE payload as
defence in depth. That changed the token wire format, and the
example_resource_server E2E suite went red: every browser PKCE hand-off
timed out before reaching the resource server's /account, while the auth
server's own suites (api_contract included) stayed green. The auth server's
own flows were unaffected because they mint and verify with the same build;
the resource-server boundary is where the extra claim did not belong.

Revert the outer-payload claim entirely (generate.ts, payload_data.ts,
decode.ts) and keep only the signature-level enforcement in
verify_signature.ts — which is the finding's recommended fix and needs no
token-format change. The signature's `type` claim has always been present,
so this closes both confusion directions (refresh token used as an access
token; access token redeemed as a refresh token) for every token in
circulation, and tokens go back to byte-identical shape.

Verified: jwt / auth-server-sdk / auth-server unit suites green, and a live
auth server refuses a refresh token sent as `Authorization: Bearer` (401 on
user, org and admin routes) and an access token sent as
grant_type=refresh_token (invalid_grant), with genuine tokens still working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
…kens

Documents that the token-type fix is graceful: it adds no claim to the
outer JWE payload, so a token on the wire is unchanged from before the fix
(and from every token still in circulation). The new test decodes such a
legacy-shaped token for its own type, asserts the outer payload carries no
`type` field, and confirms the same token is still refused when presented
as the other type (enforcement comes from the inner signature's `type`,
minted since well beyond the longest token lifetime).

Test-only: jwt's build strips *.test.* from the published dist, so the
published artifact is unchanged and no downstream cascade bump is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
…-app refresh token as a bearer

The token-type separation fix exposed that the client SDK's own whoami call
relied on the confusion it closes. For a client whose refresh token is not
an HTTP-only cookie (inline delivery — every app but the auth server's own),
checkIfAuthenticatedWithServer authenticates GET /api/auth/whoami/{client_app_id}
with `Authorization: Bearer <refresh token>`. whoami decoded every bearer
credential as an access token, so once a refresh token could no longer pose
as an access token the call returned 401, the SDK concluded the user was
signed out, and the browser never reached the resource server's /account —
the example_resource_server E2E suite's only failure.

Accept the client app's refresh token as a bearer credential on whoami,
decoded as a refresh token (never an access token) and only when its `app`
claim is the path's client_app_id. This is the same credential whoami
already takes as the refresh_token_<client_app_id> cookie, just in the
header. Only whoami lists the new scheme, so a refresh token presented as a
bearer still unlocks nothing elsewhere: every other session-guarded
operation accepts only the access-token bearer scheme, which rejects a
refresh token. whoami returns the caller's own user data, so the bearer
refresh path grants no more than the cookie one already did.

Verified against a running auth server: whoami accepts the session refresh
token as a bearer (200), the same token is still refused as a bearer on
/api/user/profile, /api/me/organizations and /api/admin/users/list (401),
whoami refuses a refresh token issued to another client app (401), and the
refresh cookie path is unchanged. E2E assertions added to the api_contract
TokenTypeSeparation spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
A redis-server started in the repo root for local verification writes a
dump.rdb there, which otherwise shows up as an untracked file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
@jalexw
jalexw merged commit 03db85e into main Oct 10, 2026
35 checks passed
@jalexw
jalexw deleted the claude/fix-token-type-confusion branch October 10, 2026 16:49
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