0018: Authentication is upstream
Why PermDock consumes only verified material, keeps token verification out of core and in optional-peer entries and provider adapters, names every mapper subjectFrom<Source>, and fails closed to the anonymous subject.
Status
Accepted, September 2026.
Context
Every PermDock decision starts from a subject, and the subject has to come from somewhere: a session cookie, a bearer JWT, Supabase claims, a Clerk auth object, MCP authInfo, a Web Bot Auth signature, a workload identity. The material arrives in a dozen formats, each with its own verification procedure, and the procedure needs cryptography (JWS signature checks, JWKS fetching, DPoP proof validation) that core cannot carry: ADR 0015 fixes core at one runtime dependency, @standard-schema/spec, and a budget of about 3 kB gzip.
At the same time the failure modes are well known and severe. RFC 8725 and its successor draft rfc8725bis list them: alg: none, key confusion between RSA and HMAC, tokens that name their own key via jku or jwk, unchecked aud, missing clock tolerance. The Supabase and Clerk data models both contain user-editable metadata sitting next to server-set metadata in the same token, and a mapper that reads the wrong one hands a user the power to promote themselves. The FAPI 2.0 Security Profile additionally requires resource servers to reject tokens in query strings and to verify sender constraint. A library that touches tokens at all has to get all of this right, or must define precisely where it stops.
The two-principal subject (ADR 0012) makes the question sharper: actor and delegation are what an agent may do for a user, so they must come from verified token claims (scope, authorization_details, act), never from the agent's own assertion.
Decision
- PermDock consumes verified material only. Core never sees a token, a cookie or a signature. It receives a
Subject(or a principal object, ornull) and treats it as true. What counts as verified is defined on one page, Authentication and PermDock, and every adapter and provider follows it. - Verification lives outside core, in two kinds of entry.
permdock/jwtverifies bearer JWTs withjoseas an optional peer dependency and implements the RFC 8725 checklist plus FAPI 2.0 as an opt-in profile. Provider adapters (permdock/supabase,permdock/clerk,permdock/better-auth,permdock/mcp) reuse the provider SDK's verification and only map its output. No verification code, and no crypto dependency, is ever added topermdockcore or to client entries. - Every mapper is
subjectFrom<Source>.subjectFromJwt,subjectFromSupabase,subjectFromClerk,subjectFromBetterAuth,subjectFromMcp. The name says what the input is; the return type is alwaysSubject; the function never throws and never logs anyone in. - Fail closed to anonymous. Material that cannot be verified, or that verifies but has no usable principal id, yields
principal: nullplus an audit event carrying areasoncode. Anonymous has no roles, so the decision isdenied. There is no "trust the claims with a warning" mode and no partially trusted principal. - Only server-set claims feed grants.
subis the id; roles and tenant come from server-controlled claims (app_metadata, Auth Hook claims, backend-set metadata) or from the policy'scontextfunction; user-editable metadata is never read for authorization. - Sender constraint is recorded by core and checked by adapters.
binding(cnf.jkt,cnf.x5t#S256) travels on the subject and into audit; proof-of-possession is verified where the request is, inpermdock/jwtor the server kernel.
Consequences
- Core stays at one dependency and under budget;
joseis installed only by apps that importpermdock/jwt, andtests/bundleasserts that no client entry reaches it. - One documented checklist replaces per-adapter token handling; the threat table gains explicit rows for
none/ key confusion, unverified claims, query-string tokens, stale tokens and metadata escalation (threat model). - Provider pages gain a "Verified material" section that states exactly which SDK call produces the accepted input and which fields are trusted.
SubjectgrowsexpiresAt,principal.kind,principal.assuranceandbinding, so a snapshot can expire with the token and a policy can require MFA or distinguish workloads (subject).- Agent-run processes (CLIs, workers) cannot assert an actor through flags; they present a token or run anonymous. This makes the terminal adapter stricter than most CLI tooling and is intentional.
- Revocation before
expis outsidepermdock/jwt; it is the job of the SSF receiver or an introspection step the app adds. The docs say so rather than implying a guarantee. - Teams with a custom identity provider write one resolver ending in
subjectFromJwt; teams with an unusual token format write a resolver that ends in a plainSubjectand accept that PermDock cannot check their verification.
Alternatives considered
- Bundle
josein core socreatePermDockcan take a raw token. Rejected: it breaks ADR 0015, ships a JWS verifier to browsers and React Native that never verify anything, and blurs the trust boundary (is this argument a token or a subject?). An optional entry gives the same convenience for the apps that need it. - Ship a full authentication layer (login, sessions, OAuth client, refresh). Rejected: Better Auth, Clerk, Supabase Auth and the framework session layers already do this well; PermDock would compete with its own providers and take on the security surface of an identity product.
permdock/jwtdeliberately stops at verification of a token someone else obtained. - Accept unverified claims with a warning (decode the JWT, log "unverified", proceed). Rejected: a warning is not a control. The first app to ship with the warning silenced has an authentication bypass, and PermDock's fail-closed invariant would hold in name only. The anonymous subject is the only acceptable output for unverified input.
- Verify in each framework adapter (Hono middleware verifies, Express middleware verifies, ...). Rejected: N copies of the RFC 8725 checklist drift; one
permdock/jwtentry plus provider mappers keeps a single implementation, which is also the lesson of the shared server kernel. - Trust
user_metadatawhenapp_metadatais empty as a convenience for small projects. Rejected: the convenience is exactly the escalation path, and small projects grow.
Related
0017: Docs first, in Fumadocs-ready MDX
Why research, design and decisions are written as MDX under apps/docs/content/docs before any code or docs app exists, and the conventions that make that work.
0019: x-permdock-* extensions, registered fallbacks only, Overlay output
Why PermDock's OpenAPI output lives in a registered x-permdock- namespace, borrows x-oai-* names only when the OpenAPI Initiative has registered them, prefers emitting an Overlay to mutating the source document, and defaults to target 3.2 while tracking 3.3.