JWT
permdock/jwt verifies bearer JWTs against a JWKS or secret with jose as an optional peer and returns a PermDock subject: principal from sub, delegation from scope and authorization_details, actor from act, sender binding from cnf. Verification failure yields the anonymous subject, never an exception.
Status: planned Phase: 1
permdock/jwt is the one place in the permdock package that verifies a token. It exists because core cannot: core has no runtime dependency other than @standard-schema/spec (ADR 0015), and signature verification needs a crypto library. permdock/jwt takes jose as an optional peer dependency, applies the RFC 8725 checklist from Authentication and PermDock, and maps the verified claims to a subject. It is used by HTTP adapters that receive bearer tokens directly and by the MCP adapter when the SDK exposes the raw token.
Purpose
Most apps already have something that verifies tokens: a framework session, a provider SDK, the MCP SDK's bearer middleware. Those go through the provider adapters or the framework adapters. permdock/jwt covers the remaining cases: an API that accepts tokens from a generic OAuth 2.0 / OIDC issuer, a service that receives client-credentials or workload tokens, a transaction token inside a trust domain, or an MCP server that wants to read authorization_details and cnf the SDK does not surface. In each case the output is a Subject with principal, actor, delegation and binding filled from claims, so the rest of PermDock behaves exactly as with a session.
API
import { subjectFromJwt, createJwtSubjectResolver, verifyDpopProof } from 'permdock/jwt'
const subject = await subjectFromJwt(token, {
jwks: new URL('https://login.example.com/.well-known/jwks.json'), // URL | JSONWebKeySet | { secret: Uint8Array }
issuer: 'https://login.example.com',
audience: 'https://api.example.com',
algorithms: ['ES256', 'PS256', 'EdDSA'], // explicit allow-list; 'none' is never accepted
clockTolerance: 5, // seconds
claims: {
id: 'sub', // dot paths, own-property lookups only
roles: 'roles', // default; RFC 9068, global roles
groups: 'groups', // default; RFC 9068, team memberships keyed on SCIM `value`
entitlements: 'entitlements', // default; RFC 9068, roles under entitlements-are-roles
tenant: 'org_id', // no standard claim; vendor specific
memberships: 'tenants', // optional; a per-tenant object such as Descope `tenants`
assurance: 'acr',
},
groupRoles: { '9f2c': ['lead'] }, // group id -> declared team roles; ids, never display names
schema: CustomClaims, // optional Standard Schema for the remaining custom claims
delegation: {
scopes: 'scope', // space-separated string or array
authorizationDetails: 'authorization_details', // RFC 9396
access: 'access', // GNAP, RFC 9635 section 8
},
actor: { from: 'act' }, // or (claims) => Actor
sender: 'dpop', // 'none' | 'dpop' | 'mtls'
profile: 'fapi2', // optional; see below
})| Export | Role |
|---|---|
subjectFromJwt(token, options) | Verifies one token and returns a Subject. token may be undefined or null (no header), in which case the result is anonymous without an audit event. Never throws. |
createJwtSubjectResolver(options) | Returns (token, request?) => Promise<Subject> with a cached JWKS. Use one resolver per issuer for the life of the process; pass it as the subject option of a server adapter. |
verifyDpopProof(request, claims) | Checks the DPoP header of request against claims.cnf.jkt: proof signature, htm / htu match, iat window, ath hash of the access token. Returns { ok: true } or { ok: false, reason }. Called automatically when sender: 'dpop'; exported for adapters that verify tokens elsewhere. |
Option notes:
claims.*paths are resolved with own-property lookups;__proto__,constructorandprototypesegments are rejected at configuration time (threat model invariant 4). A path that resolves to nothing leaves the field absent; a missingidpath makes the subject anonymous.claims.kindmay name a claim or be a literal ('workload') so client-credentials tokens produceprincipal.kind: 'workload'(subject, "Workload principals").claims.roles,claims.groupsandclaims.entitlementsdefault to the RFC 9068 claim names and accept plain string arrays or SCIM complex values ({ value, display, type }), matching onvalueonly.rolesandentitlementsfillprincipal.roles;groupsbecome{ team: value, roles: groupRoles[value] ?? [], via: 'group:<value>' }memberships inside the active tenant. A group id that has nogroupRolesentry is a membership with no roles: visible topermdock.memberships(), contributing no grant (JWT authorization claims).claims.tenantnames the active tenant claim (org_id,tid,hd,org_code); there is no standard. A missing or unexpected value yields a principal without a tenant, never a default.claims.membershipspoints at a per-tenant object or array (Descopetenants, Zitadel project roles) and maps each entry to a{ tenant, roles }membership; the claims standard lists the vendor shapes.schema(any Standard Schema) validates the claims that are not covered byclaims.*before they becomeprincipal.claims; an invalid claim set dropsclaims, never the subject, and reportsinvalid-claimsonon('auth').memberships(aMembershipSource) supplements the token with memberships from your tables for issuers that carry none; it runs after verification with the verifiedsub.actor.from: 'act'takes the innermostact.subasactor.idwithkind: 'oauth-actor'and stores the full nesting asdelegation.chain. A function receives the verified claims and returns anActororundefined.sender: 'dpop'attachesbinding: { method: 'dpop', thumbprint: cnf.jkt }and, when arequestis available, runsverifyDpopProof.sender: 'mtls'attachesbinding: { method: 'mtls', thumbprint: cnf['x5t#S256'] }and compares it to the certificate thumbprint the adapter passes in. The binding goes onactorwhen anactchain is present, otherwise onprincipal.expiresAton the returned subject isexp, ormin(exp, session_expiry)when the IPSIE / Enterprise Extensions claim is present;snapshot()copies it.
JWKS caching
createJwtSubjectResolver fetches the JWKS lazily on first use and caches it:
- The cache honours
Cache-Control: max-ageon the JWKS response, with a configurable floor and ceiling (jwksCache: { minTtl, maxTtl }). - An unknown
kidtriggers a refetch at most once perjwksCache.cooldownseconds (default 60), so a flood of tokens with boguskidvalues cannot turn the resolver into a JWKS-fetch amplifier. - A fetch error keeps the previous key set until it expires, then fails closed: tokens are rejected with reason
jwks-unavailable. Nothing is ever verified against an empty or partially fetched set. - Key rotation with a standby key (Supabase's standby / current / previously used / revoked model, or any issuer that publishes the next key ahead of time) needs no configuration: the new
kidis found on the next refetch.
Behaviour on invalid tokens
Every failure produces the same outcome: the anonymous subject (principal: null, no actor, no delegation) and one on('auth') audit event with a reason code. can on the resulting PermDock returns false; decide returns denied with reason anonymous. Nothing throws.
| Input | Result | reason |
|---|---|---|
| Signature does not verify | anonymous | invalid-signature |
exp in the past beyond clockTolerance | anonymous | expired |
nbf or iat in the future beyond clockTolerance | anonymous | not-yet-valid |
aud does not contain the configured audience | anonymous | wrong-audience |
iss differs from the configured issuer | anonymous | wrong-issuer |
alg not in algorithms | anonymous | alg-not-allowed |
alg: none (with or without a signature) | anonymous | alg-none |
kid absent from the JWKS after one refetch | anonymous | unknown-kid |
Token carries jku, x5u or jwk headers | ignored; verification proceeds against the configured keys only | none (logged at debug) |
| Token is not a JWS (malformed, JWE without a configured key) | anonymous | malformed |
sender: 'dpop' and the DPoP proof is missing or invalid | anonymous | dpop-proof-invalid |
sender: 'mtls' and the certificate thumbprint differs from cnf.x5t#S256 | anonymous | mtls-binding-mismatch |
profile: 'fapi2' and no cnf claim | anonymous | sender-constraint-required |
profile: 'fapi2' and the token arrived in a query parameter | anonymous | token-in-query |
| JWKS could not be fetched and no cached set remains | anonymous | jwks-unavailable |
The audit event carries the reason, the kid and alg seen, the issuer claimed and the request id when the adapter has one. It never carries the token.
What profile: 'fapi2' enforces
Setting profile: 'fapi2' applies the resource-server and cryptography requirements of the FAPI 2.0 Security Profile as configuration defaults that cannot be loosened:
- Token location (5.3.4): the access token is accepted only from the
Authorizationheader (RFC 6750 section 2.1) or theDPoPheader scheme (RFC 9449 section 7.1). A token in a query parameter (RFC 6750 section 2.3) is rejected withtoken-in-query, even if it would otherwise verify. - Validity, integrity, expiration (5.3.4): the full checklist above;
clockToleranceis capped at a few seconds. - Sender constraint (5.3.4): the token must be sender-constrained via mTLS (RFC 8705) or DPoP (RFC 9449);
sender: 'none'is not accepted under this profile, and a token withoutcnfis rejected withsender-constraint-required. - Cryptography (5.4.1):
algorithmsis restricted toPS256,ES256andEdDSA; RSA keys under 2048 bits and EC keys under 224 bits in the JWKS are skipped;noneremains impossible. - Sufficient authorization (5.3.4): the profile asks the resource server to verify that the token's authorization covers the requested access. That is PermDock's decision itself: the token's
scopeandauthorization_detailsbecomedelegation, and the permission isdeniedwith reasonnot-delegatedwhen they do not cover it. The FAPI 2.0 note recommending RFC 9396 whenscopeis not expressive enough is whydelegation.authorizationDetailsis a first-class field (FAPI 2.0).
Usage
Inside a Hono route
import { Hono } from 'hono'
import { createPermDock } from 'permdock/hono'
import { createJwtSubjectResolver } from 'permdock/jwt'
import { policy } from './policy'
import { permissions } from './permissions'
const resolve = createJwtSubjectResolver({
jwks: new URL('https://login.example.com/.well-known/jwks.json'),
issuer: 'https://login.example.com',
audience: 'https://api.example.com',
algorithms: ['ES256'],
claims: { id: 'sub', roles: 'app_metadata.roles' },
delegation: { scopes: 'scope' },
sender: 'dpop',
})
export const { permdock, protect } = createPermDock(policy, {
subject: (c) => resolve(bearerFrom(c.req.raw.headers), c.req.raw), // request enables the DPoP proof check
})
const app = new Hono()
app.use(permdock())
app.patch('/posts/:id', protect(permissions.post.update, (c) => loadPost(c.req.param('id'))), handler)bearerFrom reads the Authorization header only; the resolver itself never looks at the URL. A request with no header resolves to anonymous, and protect answers 401 with WWW-Authenticate for permissions any role could grant, 403 otherwise (server kernel).
Inside the MCP adapter
The MCP SDK verifies the bearer token and attaches authInfo with scopes, clientId and expiresAt. When the SDK also exposes authInfo.token, permdock/jwt can re-read the claims the SDK does not surface, such as authorization_details, act and cnf:
import { createPermDock } from 'permdock/mcp'
import { createJwtSubjectResolver } from 'permdock/jwt'
const resolve = createJwtSubjectResolver({ /* same options as above */ })
const { protectServer } = createPermDock(policy, {
subject: async (authInfo) => {
const subject = await resolve(authInfo.token)
return subject.principal // the adapter still fills actor = clientId, delegation = scopes
},
delegation: async (authInfo) => (await resolve(authInfo.token)).delegation, // adds authorization_details, access
})The SDK's verification and the resolver's verification must agree on issuer and audience; the resolver's result is the one PermDock trusts for claims the SDK did not check. Resolver calls are memoised per token within one request, so the two lines above verify once.
What it validates
- Signature,
alg,kid,iss,aud,exp,nbf,iatas listed in the behaviour table. - DPoP proof (
sender: 'dpop') and mTLS thumbprint (sender: 'mtls') when the adapter supplies the request or certificate. - Claim path safety at configuration time.
- Role names against the policy: unknown names are dropped with a development warning, unless a
customRolessource resolves them for the token's tenant. - Group and tenant identifiers are compared as opaque strings;
displaysub-attributes and email domains are never used (tenancy). - Nothing about the user beyond the token: no introspection, no userinfo call, no revocation list. Revocation before
expis the job of the SSF receiver or of an introspection step you add to your resolver.
Not an authentication library
permdock/jwt does not log users in, redirect to an authorization server, issue, refresh or revoke tokens, manage sessions or cookies, or implement any OAuth grant. It consumes a token that a client obtained elsewhere and verifies it. If you need the client side of OAuth, use your provider's SDK or an OAuth client library and hand the resulting token to subjectFromJwt (Authentication and PermDock).
How denials surface
The resolver produces subjects, not denials. A verification failure becomes an anonymous subject, and the adapter in use turns the subsequent decision into its normal denial: RFC 9457 401 / 403 from HTTP adapters (Problem Details), an isError result from MCP. The reason code is on the on('auth') audit event and in the permdock/otel span, never in the response body, so a caller cannot probe the verifier with crafted tokens.
Example app
No example of its own. permdock/jwt is exercised inside apps/examples/hono (bearer tokens from a local issuer, DPoP on one route) and apps/examples/mcp-server (the fake authorization server issues tokens with authorization_details, which the resolver reads next to the SDK's authInfo).
Bundle budget
permdock/jwt is server-only. jose is an optional peer dependency and is never bundled into the entry; client entries (permdock/react, permdock/react-native, permdock/webmcp, the client half of framework adapters) do not import permdock/jwt, and tests/bundle asserts it. The entry's own budget covers claim mapping and the JWKS cache logic only.
Related standards
- FAPI 2.0 Security Profile: sections 5.3.4 and 5.4.1, enforced by
profile: 'fapi2'. - JWT authorization claims: RFC 9068
roles,groups,entitlements, SCIM encoding, vendor tenant claims. - GNAP: the
accessarray mapped todelegation.access. - OAuth agent delegation: RFC 9396
authorization_details, RFC 8693actchains. - Shared Signals (SSF / CAEP): revocation before
exp.
Open questions
- Whether
createJwtSubjectResolvershould accept several issuers (a map fromissto options) or whether multi-issuer setups compose resolvers themselves. - Whether the
on('auth')event should be a separate event name or adecisionevent withoutcome: 'denied'andreason: 'anonymous'plus the verification reason. - Whether
verifyDpopProofneeds a replay cache forjtior whether theiatwindow is enough for PermDock's purposes. - The exact
actor.kindforact-derived actors ('oauth-actor'is a placeholder) and how it lines up with the MCP adapter's'mcp-client'. - Whether
groupRolesshould also accept a function(groupId, claims) => string[]for issuers whose group ids are tenant-prefixed.
Convex
The permdock/convex provider builds a PermDock subject from ctx.auth inside Convex queries, mutations and actions, and ships the snapshot to the client through a Convex query.
Testing
@permdock/testing ships policy matrix tests over roles, permissions and fixtures, snapshot fixtures for UI adapters, an RLS parity runner, Next.js instant() helpers and Vitest type tests.