JWT
permdock/jwt verifies bearer JWTs against a JWKS, an OpenID Connect Discovery document or a secret with jose as an optional peer and returns a PermDock subject: principal from iss and sub, delegation from scope, authorization_details and access, actor from act, sender binding from cnf. Verification failure yields the anonymous subject, never an exception, and every failure has an RFC 6750 rendering.
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, and signature verification needs a crypto library. permdock/jwt takes jose as an optional peer dependency, applies the RFC 8725 checklist from JOSE, and maps the verified claims to a subject following OpenID Connect. It is used by HTTP adapters that receive bearer tokens directly and by the MCP adapter when the SDK exposes the raw token. It also holds the package's TokenVerifier and TokenSigner implementations, so it is where JWS-signed snapshots are produced.
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, {
discovery: "https://login.example.com", // OIDC Discovery, RFC 8414 fallback; supplies jwks_uri and issuer
// jwks: new URL('https://login.example.com/.well-known/jwks.json'), // alternative: URL | URL string | JSONWebKeySet | { secret: Uint8Array }
// issuer: 'https://login.example.com', // required with any `jwks` but a secret; derived from the document with `discovery`
audience: "https://api.example.com",
accept: "access-token", // default; 'id-token' for BFF patterns
algorithms: ["ES256", "PS256", "Ed25519"], // default allow-list (+ 'RS256' outside fapi2); 'none' never
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, maps to principal.plans
tenant: "org_id", // no standard claim; vendor specific
memberships: "tenants", // optional; a per-tenant object such as Descope `tenants`
assurance: {
acr: "acr",
amr: "amr",
authTime: "auth_time",
verified: "verified_claims",
}, // defaults; OIDC Core section 2, OIDC4IDA
session: "sid", // default; joins logout_token and CAEP events to the snapshot
},
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 access array; JWT mapping per RFC 9767 section 2.1
},
actor: { from: "act" }, // RFC 8693; or (claims) => Actor
sender: "dpop", // 'none' | 'dpop' | 'mtls'
decryptionKeys: undefined, // JWE accepted only when set; see "JWE posture"
profile: "fapi2", // optional; see below
// verifier: myTokenVerifier, // replaces the built-in jose verifier; see "TokenVerifier"
});| 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 Discovery document and 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, accessToken?, { replay }?) | Checks the DPoP header of request against claims.cnf.jkt: an asymmetric alg and a public jwk only, proof signature, htm match, htu against the request URI without its query and fragment (RFC 9449 section 4.3), iat window, ath hash of the access token. With replay, a proof without a jti, or with a jti the same key used in the last 60 seconds, is invalid. Returns { ok: true } or { ok: false, cause }. Called automatically when sender: 'dpop'; exported for adapters that verify tokens elsewhere. |
joseTokenVerifier(options) | The built-in TokenVerifier: jwks or discovery, algorithms, typ, audience, clockTolerance, decryptionKeys. Exported so permdock/ssf, permdock/cloud and your own code verify PermDock's signed outputs and SETs with the same rules (extension interfaces). |
joseTokenSigner({ key, alg, kid }) | The built-in TokenSigner: signs a payload as compact JWS with a registered typ. Used by permdock.snapshot({ signer }), approvalsHandler({ signer }) and permdock/cloud (wire formats). |
subjectFromCapability(token, options) | Verifies a permdock-capability+jwt share link and returns the link subject it acts as: one resource membership, narrowed by delegation.scopes when the capability lists permissions. issuer and audience are required. Never throws (link capabilities). |
subjectFromIntrospection(response, options) | Maps an RFC 7662 or RFC 9767 introspection response (active, access / scope, key, sub, iss, instance_id / client_id) to the same Subject shape; active other than true is anonymous. With audience set, aud must contain it; with issuer set, a response whose iss differs is anonymous, and a response without iss has no principal.issuer. The HTTP call to the introspection endpoint is yours (GNAP, delegation). |
Option notes:
discoverytakes an issuer URL (or{ issuer, metadata }when the document is already loaded). The resolver fetches<issuer>/.well-known/openid-configuration, falls back to the RFC 8414 path/.well-known/oauth-authorization-server/<path>, requires the document'sissuerto equal the configured one byte for byte, takesjwks_urifrom it (anhttp:jwks_uriis never fetched, and inmetadatait is a configuration error) and caches both with the JWKS rules below.discoveryandjwksare mutually exclusive; withdiscovery,issueris derived (joseTokenVerifierchecks every token'sissagainst it), and setting it explicitly is a configuration error unless it matches. With ajwksURL or key set,issueris required: identity providers share key sets across tenants and issuers, so a signature alone does not say who issued the token. Fetching happens in the resolver, never indecide.accept: 'access-token'(default) verifiestypasat+jwtorapplication/at+jwt(RFC 9068 section 4) orJWT; underprofile: 'fapi2'onlyat+jwt. A token that carriesnonce, or whoseaudis a client identifier rather than the configuredaudience, is an ID token and fails with causewrong-token-type.accept: 'id-token'verifies an ID token per OpenID Connect Core section 3.1.3.7 (audcontains the client id,azpis present when several audiences are and equals the client id whenever present,iatis present,typabsent orJWT) for backends-for-frontends that want its claims as the principal. One resolver accepts one kind.algorithmsdefaults to['ES256', 'PS256', 'Ed25519', 'RS256']and to['ES256', 'PS256', 'Ed25519']underprofile: 'fapi2'.Ed25519is the RFC 9864 fully-specified name; a token withalg: EdDSAverifies only when the selected JWK iskty: OKPwithcrv: Ed25519, andpermdock doctorreports issuers that still publishEdDSA.HS256is accepted only together with{ secret }of at least 256 bits.noneand the JWERSA1_5are never accepted.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.principal.issueris always set from the verifiediss(or the Discovery document'sissuer).principal.idalone is not an identity across issuers; audit events carry both.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.rolesfillprincipal.roles;entitlementsfillprincipal.plans.groupsbecome{ tenant, roles: groupRoles[value] ?? [], via: 'group:<value>' }memberships of the active tenant (none without one): group roles hold in the tenant, and the group itself is only thevia. 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 (Zitadel'srole -> { orgId: domain }form becomes one membership per organisation holding every role listed under it; an entry already in the canonical{ scope, id, within?, roles, via?, expiresAt? }form is kept, withviaandexpiresAt); the claims standard lists the vendor shapes.claims.assurancefillsprincipal.assuranceas{ acr, amr, authTime }from the OIDC claims of the same name (amras an RFC 8176 array,authTimein seconds). A string value ('acr') is shorthand for{ acr: 'acr' }; provider adapters map Supabaseaalintoacr. Conditions compareacras an opaque string andamrby membership; a denial on either has reasoninsufficient-user-authenticationand renders as an RFC 9470 challenge (below). RFC 8176 values commonly seen:pwd,otp,swk(software key / passkey in a platform authenticator),hwk(hardware key),mfa.
Verified material
| Claim | Trust | Becomes |
|---|---|---|
acr, amr, auth_time | Verified by TokenVerifier | principal.assurance.acr, .amr, .authTime |
verified_claims | Verified by TokenVerifier; entries without verification.trust_framework and claims dropped | principal.assurance.verified, frozen and opaque |
claims.sessionfillssubject.sessionfromsidso a later Back-Channel Logoutlogout_tokenor CAEPsession-revokedevent can be matched to the snapshot (SSF adapter).schema(any Standard Schema) validates the claims that are not covered byclaims.*before they becomeprincipal.claims. The OpenID Connect Core section 5.1 profile claims (email,name,picture,preferred_usernameand the rest) never reachprincipal.claims; an invalid claim set dropsclaims, never the subject, and reports causeinvalid-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 outermostact.sub, the current actor per RFC 8693 section 4.1, asactor.idand stores the full nesting asdelegation.chain; prior actors in nestedactclaims are for audit and never decide access. A claimedactthat is not an object, or any level of which lacks a non-empty stringsub, is anonymous with causeinvalid-chain.actor.kindis'oauth-client'by default and the MCP adapter'sactorKind('mcp-client'unless set) when the resolver runs insidepermdock/mcp; there is no otherkindforact-derived actors. An object config always readsact, with or withoutfrom, so setting onlykindcannot drop a delegated token's actor.actor.clients(aClientNames) setsactor.clientto the name ofact.sub, for policy delegations that name a client. A function receives the verified claims and returns anActororundefined.delegation.accessreads the GNAPaccessclaim as RFC 9767 section 2.1 defines it for JWT-formatted tokens (objects and reference strings, RFC 9635 section 8). Entries are stored ondelegation.accessand intersected with grants likescopes.sender: 'dpop'attachesbinding: { jkt: cnf.jkt }and runsverifyDpopProofon therequest; a call without arequestcannot prove possession and resolves the anonymous subject withdpop-proof-invalid.sender: 'mtls'attachesbinding: { 'x5t#S256': cnf['x5t#S256'] }and compares it to the certificate thumbprint the adapter passes in.Bindingcarries the RFC 7800cnfmembers verbatim (jkt,x5t#S256,jwk,kid) so it round-trips into anything PermDock signs. 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.verifierreplaces the built-injoseTokenVerifierwith anyTokenVerifier: another JOSE library, a hardware-backed verifier, or one that calls an introspection endpoint. The claim mapping,acceptlogic and audit events are unchanged; only the "is this token genuine" step moves.
Discovery and JWKS caching
createJwtSubjectResolver fetches the Discovery document (when discovery is set) and the JWKS lazily on first use and caches them:
- The Discovery document is fetched over HTTPS only;
http:issuers are a configuration error. Itsissuermust equal the configured issuer (Discovery 1.0 section 4.3); a mismatch is reported once bypermdock doctorand every token resolves to anonymous with causediscovery-mismatchuntil it is fixed. The document is re-read on the JWKS refetch schedule. - The JWKS 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. - Concurrent requests share one in-flight discovery or JWKS fetch. Each fetch is aborted after
jwksCache.timeoutmilliseconds (default 5000) and then counts asjwks-unavailableordiscovery-unavailable, so a slow identity provider cannot hold requests open. - A fetch error keeps the previous key set until it expires, then fails closed: tokens are rejected with cause
jwks-unavailable(ordiscovery-unavailablewhen the document itself could not be fetched and none is cached). 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.
Link capabilities
subjectFromCapability(token, options) verifies with the same joseTokenVerifier rules (or verifier) but accepts only typ: permdock-capability+jwt, and subjectFromJwt never accepts that typ, so a link and an access token cannot stand in for each other. It takes jwks or discovery, issuer and audience (both required), algorithms, clockTolerance and three options of its own:
revoked(id)returnstruefor a link id (sub) the application revoked. A throw denies.replayis aReplayStore(SSF adapter). A one-time capability claimscapability, the issuer and thejtiin it and is refused without one.vieweris the request's own verified subject, checked against the capability'sredeemer; a link subject never satisfies it.linkPolicy(capability)returns theLinkPolicy(or several) of the scope instances the linked resource sits in:maxLifetimefromiat, allowedredeemers, requiredonce. Every one must hold. A throw denies.
The result is { principal: { id, kind: 'link', issuer, memberships: [{ on, roles, via: 'link', expiresAt }], capability }, delegation?, context: {}, expiresAt }, with expiresAt the earlier of exp and capability.expiresAt. Failures report source: 'capability' on on('auth'), with the causes below in addition to the verification causes of the next table:
| Input | Result | reason, cause |
|---|---|---|
capability claim is not a valid v1 object, its id is not sub, its holder is key, or a one-time capability arrives without replay or jti | anonymous | invalid-token, invalid-claims |
redeemer is signed-in, a user or a scope instance the viewer does not satisfy | anonymous | invalid-token, redeemer-mismatch |
A linkPolicy rule is broken (lifetime from iat, redeemer kind, one-time) | anonymous | invalid-token, link-policy |
revoked(id) returned true | anonymous | invalid-token, capability-revoked |
A one-time capability whose jti was already claimed | anonymous | invalid-token, capability-replayed |
linkPolicy, revoked or replay threw | anonymous | source-threw, no cause |
CapabilityFailureCause is TokenFailureCause plus the four capability causes; a TokenVerifier never returns them.
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 reason: 'invalid-token' (the RFC 6750 error name, hyphenated) and a cause naming what failed. can on the resulting PermDock returns false; decide returns denied with reason anonymous. Nothing throws.
| Input | Result | cause |
|---|---|---|
| Signature does not verify | anonymous | invalid-signature |
exp in the past beyond clockTolerance, or exp absent (except on a secevent+jwt Security Event Token, which RFC 8417 lets omit exp and which must carry iat instead) | 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 or discovered issuer | anonymous | wrong-issuer |
typ not accepted for accept, or an ID token presented as an access token | anonymous | wrong-token-type |
alg not in algorithms, or EdDSA on a key that is not crv: Ed25519 | 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, jwk or x5c headers | ignored; verification proceeds against the configured keys only | none (logged at debug) |
crit names a header parameter the verifier does not understand | anonymous | malformed |
| Token is not a JWS or JWE (malformed) | anonymous | malformed |
Token is a JWE and decryptionKeys is not configured, or uses zip or RSA1_5 | anonymous | encrypted-token |
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 |
schema rejects the custom claims | principal without claims | invalid-claims |
Claimed act is not a nestable object with a string sub | anonymous | invalid-chain |
| JWKS could not be fetched and no cached set remains | anonymous | jwks-unavailable |
| Discovery document could not be fetched and none is cached | anonymous | discovery-unavailable |
Discovery document issuer differs from the configured issuer | anonymous | discovery-mismatch |
The audit event carries reason, cause, the kid, alg and typ seen, the issuer claimed and the request id when the adapter has one. It never carries the token. on('auth') is its own event, separate from on('decision'): a verification failure is not a decision, and the decision that follows is recorded on its own with reason anonymous.
RFC 6750 and RFC 9470 error mapping
The resolver produces subjects, not HTTP responses; the HTTP adapters render the eventual Decision. Because reason values reuse the RFC 6750 error vocabulary, the rendering needs no second table:
| Situation | Decision | HTTP status | WWW-Authenticate | Problem Details type |
|---|---|---|---|---|
| No token, permission some role could grant | denied, reason anonymous | 401 | Bearer | .../unauthenticated |
| Any row of the table above | denied, reason anonymous; the on('auth') event has reason invalid-token and a cause | 401 | Bearer error="invalid_token", error_description="The access token is invalid"; the cause is never sent | .../unauthenticated |
| Verified principal without a grant | denied, reason no-grant or deny | 403 | none | .../denied |
| Delegation does not cover the permission | denied, reason not-delegated | 403 | Bearer error="insufficient_scope", scope="<permission.scope>"; alternatives lists the permissions the token would allow | .../denied with alternatives |
| Actor without any delegation | denied, reason no-delegation | 403 | Bearer error="insufficient_scope", scope="<permission.scope>" | .../denied |
The only conditions that failed read subject.assurance | denied, reason insufficient-user-authentication (a denial reason added by subject; any other failed condition keeps the ordinary condition reason) | 401 | Bearer error="insufficient_user_authentication", acr_values="<required acr>", max_age=<seconds> (RFC 9470) | .../step-up-required with acrValues and maxAge |
| Human gate | approval-required | 403 | none | .../approval-required with token |
RFC 6750 says the resource server "SHOULD NOT" describe the failure beyond the error code to an unauthenticated caller; the cause therefore stays on the audit event and the OpenTelemetry span. The two insufficient_scope lines take scope from permission.scope, the same string the MCP adapter puts in a scopeChallenge; not-delegated and no-delegation are the Decision reasons, insufficient_scope their one HTTP rendering. The RFC 9470 line takes acr_values and max_age from the condition that failed. Problem Details documents the bodies; the adapter matrix shows every runtime's rendering side by side.
JWE posture
Encrypted tokens are accepted only when decryptionKeys is configured (a JWK Set or a single private JWK). The token must be a nested JWT: a JWE whose plaintext is a JWS, marked cty: JWT (RFC 7519 section 5.2). After decryption the inner JWS goes through every rule above unchanged; the JWE header's alg and enc must be in decryptionAlgorithms (default RSA-OAEP-256, ECDH-ES, ECDH-ES+A256KW, dir, with A128GCM, A192GCM, A256GCM as enc). RSA1_5 is never accepted, zip is rejected, and a JWE with no inner signature is treated as unauthenticated (encrypted-token): encryption alone proves nothing about who issued the token. Without decryptionKeys, any JWE (five segments) is encrypted-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;typmust beat+jwt. - 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,ES256andEd25519(RFC 9864;EdDSAaccepted forcrv: Ed25519keys only); 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).
TokenVerifier and TokenSigner
permdock/jwt implements the two JOSE interfaces core declares as types (extension interfaces):
import { joseTokenVerifier, joseTokenSigner } from "permdock/jwt";
const verifier = joseTokenVerifier({
discovery: "https://login.example.com",
algorithms: ["ES256"],
typ: "at+jwt",
});
const result = await verifier.verify(token, {
audience: "https://api.example.com",
});
// { ok: true, claims, header } | { ok: false, reason: 'invalid-token', cause: 'expired' }
const signer = joseTokenSigner({
key: privateJwk,
alg: "Ed25519",
kid: "2026-09",
});
const jws = await signer.sign(payload, {
typ: "permdock-snapshot+jwt",
expiresAt,
});verify never throws; every failure is the same { ok: false, reason, cause } shape the behaviour table lists. sign produces compact JWS with the header alg, kid, typ and nothing else. The conformance runner testTokenVerifier in permdock/testing checks a custom verifier against the behaviour table with the fixture tokens it ships.
Signed outputs
Four PermDock artefacts can be signed with a TokenSigner; the payloads are specified on wire formats:
const jws = await permdock.snapshot({
signer,
audience: "https://app.example.com",
}); // typ: permdock-snapshot+jwt
approvalsHandler({ store, signer }); // typ: permdock-approval+jwt on the resume token
cloud({ url, key }).sink; // typ: permdock-decisions+jwt on batch export
await signCapability(link, signer, { audience: "https://app.example.com" }); // typ: permdock-capability+jwt, a share linkPermDock Cloud signs one more artefact, the hosted-grant policy document (typ: permdock-policy+jwt); the application only verifies it, through the verifier passed to cloud().
A client in any language verifies a signed snapshot with its JOSE library and the signer's JWKS; in TypeScript, joseTokenVerifier({ jwks, typ: 'permdock-snapshot+jwt' }). The unsigned JSON form remains the default and the two carry the same snapshot object.
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({
discovery: "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), // the request carries the DPoP proof
});
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,typ,iss,aud,exp,nbf,iatas listed in the behaviour table; the RFC 8725 checklist row by row is on JOSE. - DPoP proof (
sender: 'dpop') and mTLS thumbprint (sender: 'mtls'); without the request or certificate the token resolves anonymous.verifyDpopProofbounds proofs by theiatwindow; passreplay: memoryReplayStore()(or a sharedReplayStoreacross instances) tocreateJwtSubjectResolverto refuse a reusedjtias well. - The Discovery document's
issuerand transport. - 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 userinfo call, no revocation list. Revocation before
expis the job of the SSF receiver (CAEP events and OIDClogout_token) or of an introspection step you add throughverifier.
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). Nonce handling therefore stays with the OAuth client that received the token; a token carrying nonce is an ID token and is refused as an access token. One resolver serves one issuer; a multi-issuer API picks a resolver by the unverified iss and lets that resolver verify. groupRoles is a static map from group id to roles; tenant-prefixed group ids go through a MembershipSource.
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 with the WWW-Authenticate value from the mapping above (Problem Details), an isError result from MCP. The cause 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 discovered through /.well-known/openid-configuration, DPoP on one route, one signed snapshot endpoint) 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, the Discovery and JWKS cache logic and the two interface implementations only.
Related standards
- OpenID Connect: Discovery, ID token vs access token, the claim-to-Subject table, RFC 9470 step-up, Back-Channel Logout.
- JOSE: the RFC 8725 checklist row by row, RFC 9864, the interoperability contract for signed outputs.
- 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
accessclaim (RFC 9767) mapped todelegation.accessand intersected with grants;subjectFromIntrospectionfor RFC 7662 and RFC 9767 responses. - OAuth agent delegation: RFC 9396
authorization_details, RFC 8693actchains. - Shared Signals (SSF / CAEP): revocation before
exp.
Last updated on
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, and Vitest type tests.