Skip to content

feat(jwt): apply JWT templates and pin the signing key - #32

Merged
gjtorikian merged 3 commits into
mainfrom
feat/jwt-template-claims-and-pinnable-signing-key
Aug 4, 2026
Merged

feat(jwt): apply JWT templates and pin the signing key#32
gjtorikian merged 3 commits into
mainfrom
feat/jwt-template-claims-and-pinnable-signing-key

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Summary

  • JWT template claims never reached the token. PUT /user_management/jwt_template
    accepted a template, stored it, and echoed it back from GET — while nothing read it at
    sign time. The endpoint looked implemented and did nothing, which is worse than not
    offering it. Templates now render into every AuthKit access token.
  • The stored shape was invented. custom_claims appears nowhere in
    @workos/openapi-spec; the real API takes content, a template string, and returns
    created_at/updated_at. The route now matches the spec, and GET 404s before a
    template is set.
  • Implemented the interpolation syntax WorkOS documents — not Liquid, so no filters,
    conditionals, or loops.
  • The signing key and issuer can now be pinned. The emulator generated a fresh keypair every boot, so a restart invalidated the
    published JWKS along with every token already issued, and iss moved with the port.
    Pinning both lets a verifier treat them as constants, so the service under test needs no
    environment-specific auth branch — only its usual JWKS fetch.
  • Validation is front-loaded. A malformed seeded template fails the boot and is caught
    by --validate-config, rather than surfacing at the first sign-in. A template rendering
    past WorkOS's 3072-byte cap fails authenticate with a 422 naming the size instead of
    quietly dropping claims.

Reserved claims

A template may not set iss, sub, exp, iat, nbf, or jti — rejected when the
template is set, and stripped at sign time as a backstop. aud, sid, org_id, role,
roles, and permissions are deliberately not reserved: the docs don't reserve them, so
a template may override them and the rendered value wins.

Fields the emulator has no data for — organization.allow_profiles_outside_organization
and organization_membership.custom_attributes — resolve to null rather than being filled
with a plausible-looking default.

A customer evaluating the emulator for API integration tests hit two
blockers. `PUT /user_management/jwt_template` accepted a template and
echoed it back, but the claims never reached a signed token — the
endpoint looked implemented while doing nothing, which is worse than
not offering it at all. The stored shape was invented too: the real
API takes `content`, a template string, and has no `custom_claims`
field anywhere in the spec.

They also asked for a per-environment test signing key. The emulator
generated a fresh keypair on every boot, so a restart invalidated the
published JWKS along with every token already issued, and `iss` moved
with the port. Pinning the key and the issuer lets a verifier treat
both as constants and keeps tokens valid across a restart, so the
service under test needs no environment-specific auth branch — only
its usual JWKS fetch.

Templates are validated when set and when seeded, so a bad one fails
the boot rather than the first sign-in, and a template that renders
past the 3072-byte limit fails the authenticate call by name instead
of silently dropping its claims.

BREAKING CHANGE: `PUT /user_management/jwt_template` now requires
`content` and rejects `custom_claims` with a 422 naming the
replacement. `custom_claims` was emulator-only and never reached a
token, so no working setup depends on it. `GET` returns the spec
shape (`content`, `created_at`, `updated_at`) and 404s before a
template is set.
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds JWT-template claims to AuthKit access tokens and supports stable signing keys and issuers. It also moves template rendering before session persistence, although invitation acceptance still occurs first.

  • Implements JWT-template storage, validation, interpolation, and token claim merging.
  • Adds configurable RSA signing keys, stable key IDs, and issuer overrides.
  • Adds seed configuration, CLI options, documentation, and integration coverage.
  • Prevents failed template rendering from persisting sessions, refresh tokens, or sign-in timestamps.

Confidence Score: 4/5

The PR is not yet safe to merge because a JWT-template rendering failure can still consume an invitation without completing authentication.

The session-persistence issue has been addressed by rendering before session creation, but invitation acceptance and membership mutation still occur first and are not rolled back when rendering returns a 422.

Files Needing Attention: src/workos/routes/auth.ts; src/workos/helpers.ts

Important Files Changed

Filename Overview
src/workos/routes/auth.ts Applies rendered template claims before session persistence, but invitation acceptance still precedes rendering and remains non-transactional.
src/workos/jwt-template.ts Adds template parsing, validation, context construction, size enforcement, and rendering into JWT claims.
src/core/jwt.ts Adds guarded custom-claim merging and configurable RSA signing keys with stable derived key IDs.
src/index.ts Exposes issuer, signing-key, and seeded-template configuration through the emulator API.
src/cli.ts Adds command-line and environment configuration for signing keys, key IDs, and issuers.

Reviews (3): Last reviewed commit: "docs(jwt): note that a failed template r..." | Re-trigger Greptile

Comment thread src/workos/routes/auth.ts
A template that could not render returned its 422 after the session had
already been inserted and last_sign_in_at bumped, so a sign-in that
never handed back a token still left an orphaned session behind — along
with the session.created and user.updated webhooks that imply the login
succeeded. Rendering first makes the failure leave no trace.

The render has to stay after acceptInvitation, whose membership the
template context reads for organization_membership.role. Reading the
pre-update user record is equivalent, since no template variable
exposes last_sign_in_at.
@gjtorikian
gjtorikian merged commit f2da8b5 into main Aug 4, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the feat/jwt-template-claims-and-pinnable-signing-key branch August 4, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant