PermDock
Concepts

Authentication and PermDock

PermDock never authenticates: it consumes material something else has already verified, turns it into a subject, and decides. This page defines what counts as verified, which claims may feed grants, and how tokens map to principal, actor and delegation.

PermDock is an authorization library. It does not log anyone in, does not issue or refresh tokens, and does not store sessions. Every decision starts from verified material: a session your framework validated, a JWT whose signature and claims were checked against the issuer's keys, an authInfo object the MCP SDK attached after bearer verification, a signature the Web Bot Auth verifier accepted. PermDock's job begins where that verification ends: map the material to a subject, build a request-scoped PermDock, and answer can / decide.

The rule that follows from this is the one every adapter and provider on this site obeys: if the material cannot be verified, the subject is anonymous. Anonymous has no roles and therefore no grants. Nothing throws, nothing falls back to "trust the claims anyway", and the audit event says why (ADR 0018).

The pipeline

anonymous, audit reason Verified materialsession, JWT claims, authInfo, signature subjectprincipal + actor? + delegation? createPermDock(policy, subject) decide / can / assert Unverifiable material

Verification happens to the left of the first box and is never PermDock core's job: core has no runtime dependency other than @standard-schema/spec (ADR 0015), so it cannot verify a signature. Verification lives in three places instead:

  • Your framework or provider verifies sessions and tokens and hands PermDock the result (Next.js, Better Auth, Supabase getClaims(), Clerk auth(), the MCP SDK's bearer middleware).
  • permdock/jwt verifies bearer JWTs against a JWKS or secret with jose as an optional peer dependency and returns a subject (JWT adapter).
  • Provider adapters (permdock/supabase, permdock/clerk, permdock/better-auth) reuse the provider SDK's verification and map its output.

Each of them exposes a subjectFrom<Source> function. The name says where the material came from; the return type is always a Subject; the function never throws.

Sources of verified material

SourceVerified byBecomesAdapter
Session cookieThe framework's session layer (Next.js, Better Auth server API)principal from the session userpermdock/next, permdock/better-auth
Bearer JWTSignature, iss, aud, exp, nbf against a JWKS or shared secretprincipal from sub; global roles from RFC 9068 roles, team memberships from groups, entitlement roles from entitlements (JWT authorization claims); tenant from the configured claim; delegation from scope, authorization_details; actor from actpermdock/jwt (subjectFromJwt)
Supabase getClaims() output@supabase/supabase-js against the project JWKS (asymmetric keys) or the Auth server (legacy secret)principal.id from sub, roles and tenant from app_metadata / hook claim, assurance from aal; memberships from your tables through membershipspermdock/supabase (subjectFromSupabase)
Clerk session claimsClerk middleware and auth()principal from userId; tenant from the active orgId; a membership { tenant: orgId, roles: [orgRole] }; fea entitlement roles; custom claims through schemapermdock/clerk (subjectFromClerk)
Better Auth sessionauth.api.getSession on the serverprincipal from the user; tenant from activeOrganizationId; memberships from member and teamMember rows; custom roles from organizationRole through a RoleSourcepermdock/better-auth (subjectFromBetterAuth)
MCP authInfoThe MCP SDK's bearer verification (requireBearerAuth, RFC 9207 iss check)principal from the token's user; actor from clientId; delegation from scopespermdock/mcp (subjectFromMcp)
API keyConstant-time lookup in your key storeA service principal: { id: keyOwnerId, kind: 'service', roles }, never an actorYour subject resolver
Workload / service identityClient-credentials token, WIMSE workload identity, SPIFFE ID presented over mTLSprincipal.kind: 'workload', principal.id the SPIFFE ID or client idpermdock/jwt or your resolver
Web Bot Auth signatureRFC 9421 HTTP Message Signature against the Signature-Agent directoryactor { id: keyId, kind: 'web-bot-auth' }; the principal still comes from a token or sessionServer kernel with webBotAuth
Transaction tokenSignature by the Transaction Token Service, aud equal to your trust domainprincipal from the token's subject, principal.context from azd, workload chain as actorpermdock/jwt
AnonymousNothing to verify, or verification failedprincipal: nullEvery adapter

Two entries deserve a note. An API key identifies a caller that acts on its own behalf, so it is a principal with roles, not an actor: actors never hold grants, and a key modelled as an actor would be denied everything (subject, "Service principals"). A Web Bot Auth signature identifies who is asking, not whose authority applies, so it fills actor and leaves the principal to the token or session on the same request (Web Bot Auth).

Provider recipes

Four providers get an adapter (permdock/supabase, permdock/better-auth, permdock/clerk, permdock/convex) because each exposes a verification hook only in-process code can use. Every other provider is a recipe over subjectFromJwt or the framework session, following the rule that ecosystems are reached through wire formats rather than per-tool packages (ADR 0023). Every mapper, shipped or yours, satisfies the SubjectResolver interface, exports a base principal type and takes a schema option for custom claims (extension interfaces); how each fills tenant, team and resource memberships is summarised on tenancy and detailed in each provider page's "Memberships" section. The table records, per provider, where verification happens, which claim carries roles and organisation, and which fields are user-editable and therefore never feed grants. Claim names are the providers' defaults as documented in September 2026; confirm against the provider's current docs and pin them in the claims option.

ProviderVerified byRoles and organisationUser-editable, never grantsRecipe
Auth.js (NextAuth v5)auth() in the framework; the JWT session strategy uses an encrypted JWE under AUTH_SECRET, not a public-key JWTNone built in: add role and orgId in the jwt and session callbacks from your database, never from the OAuth profileEverything the OAuth provider returned (name, email, image)subject: async () => (await auth())?.user and map user.role in definePolicy's subject; permdock/jwt does not apply because the token is not verifiable by a third party
WorkOS AuthKit, WorkOS ConnectsubjectFromJwt against https://api.workos.com/sso/jwks/<client_id>; Connect (GA May 2026) is the OAuth 2.1 authorization server for MCP servers and issues access tokens from the same JWKSorg_id, role (organisation role slug), permissions array; managed in the WorkOS dashboard; Connect tokens carry client_id and scopeProfile fieldsclaims: { id: 'sub', roles: 'role', tenant: 'org_id' }; permissions can also feed delegation.scopes when your permission scopes match WorkOS permission slugs. Behind permdock/mcp, a Connect token is the authInfo the adapter consumes
Stytch Connected Apps (Twilio)subjectFromJwt against https://<project>.customers.stytch.com/.well-known/jwks.json; Connected Apps is the OAuth 2.1 authorization server for MCP servers with dynamic and CIMD client registrationhttps://stytch.com/organization claim (organization_id, slug) and RBAC roles on B2B session and access tokens; scope for delegated appstrusted_metadata is server-only, untrusted_metadata is member-editableclaims: { id: 'sub', roles: 'roles', tenant: 'https://stytch.com/organization.organization_id' }; never untrusted_metadata. In front of an MCP server the access token is consumed by permdock/mcp as authInfo with client_id as actor.id
DescopesubjectFromJwt against https://api.descope.com/<project-id>/.well-known/jwks.json; the Agentic Identity Hub issues tokens for MCP servers and holds outbound tokens in a vaultroles, permissions, and per-tenant tenants.<id>.roles on the session JWTUser-editable custom attributes if the app exposes themclaims: { id: 'sub', roles: 'roles' }, or tenants.<id>.roles for the active tenant; permissions can feed delegation.scopes
ScalekitsubjectFromJwt against https://<env>.scalekit.com/keys; sells the authorization-server half for MCP servers (scopes, consent, token exchange)oid (organisation), roles; agent tokens carry client_id and scopeProfile fieldsclaims: { id: 'sub', roles: 'roles', tenant: 'oid' }; agent tokens fill actor and delegation through permdock/mcp
Okta (Workforce and Customer Identity), Okta Cross App AccesssubjectFromJwt against https://<domain>/oauth2/<server>/v1/keys; Cross App Access (GA 24 August 2026) implements the Identity Assertion JWT Authorization Grant (MCP authorization, EMA) so an agent obtains a token for an MCP server on the user's behalf through the enterprise IdPgroups claim (must be added to the access token), custom claims via claim expressionsProfile attributesclaims: { id: 'sub', roles: 'groups' }. A Cross App Access token has the human as sub and the agent as client_id; permdock/mcp maps them to principal and actor unchanged, nothing PermDock-specific is required
Microsoft Entra ID, Entra Agent IDsubjectFromJwt against https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys (validate aud and tid); Entra Agent ID gives agents directory identities and tokens of their ownroles (app roles), groups (or _claim_names overflow), tid; agent tokens carry the agent's oid and azpNone in the token; directory attributes are admin-setclaims: { id: 'oid', roles: 'roles', tenant: 'tid' }; when an agent acts on behalf of a user (OBO), sub is the human and azp the agent, which fill principal and actor
FronteggsubjectFromJwt against https://<subdomain>.frontegg.com/.well-known/jwks.jsonroles, permissions, tenantId; Frontegg ships its own RBAC and entitlements (feature flags and plans)metadata if the app lets users write itclaims: { id: 'sub', roles: 'roles', tenant: 'tenantId' }; Frontegg entitlements are context or roles under the "entitlements are roles" rule, never grants. Overlapping product; consumed as claims, not competed with
PropelAuthsubjectFromJwt against https://<auth-url>/.well-known/jwks.jsonorg_id_to_org_member_info with user_role and user_permissions per organisationmetadata the user can edit through the hosted pagesResolve the active organisation, then roles: [user_role]; user_permissions can feed delegation.scopes
SuperTokens, HankosubjectFromJwt against the self-hosted or cloud JWKS (/auth/jwt/jwks.json for SuperTokens)SuperTokens st-role and st-perm claims from its UserRoles recipe; Hanko has no roles claimProfile fieldsSuperTokens claims: { id: 'sub', roles: 'st-role.v' }; Hanko resolves roles from your database in context
Auth0subjectFromJwt against https://<tenant>/.well-known/jwks.jsonpermissions (RBAC "add permissions in the access token"), org_id (Organizations), roles only through an Action writing a namespaced claim such as https://example.com/rolesuser_metadata; app_metadata is server-only, the same split as Supabaseclaims: { id: 'sub', roles: 'https://example.com/roles', tenant: 'org_id' }; never read user_metadata
LogtosubjectFromJwt against https://<tenant>.logto.app/oidc/jwksroles for API-resource RBAC; organisation tokens carry organization_id and organisation roles; custom_data is admin-setProfile and custom_data only if your app lets users write itclaims: { id: 'sub', roles: 'roles', tenant: 'organization_id' } with the organisation token as the bearer
KindesubjectFromJwt against https://<domain>.kinde.com/.well-known/jwkspermissions array, org_code, roles when enabled in token customisation, feature_flagsProfile fieldsclaims: { id: 'sub', roles: 'roles', tenant: 'org_code' }; treat feature_flags as context, not grants (policies)
Neon AuthBetter Auth hosted by Neon; JWKS published for Neon RLSSame as permdock/better-auth: user.role from the admin plugin, organisation plugin membershipProfile fieldsUse subjectFromBetterAuth server-side; for the database, the same JWT feeds auth.user_id() in generated RLS (RLS neon dialect)
Stack AuthsubjectFromJwt against https://api.stack-auth.com/api/v1/projects/<project-id>/.well-known/jwks.jsonTeam membership and team permissions from the server SDK; serverMetadata is server-onlyclientMetadata; clientReadOnlyMetadata is server-written but client-visibleResolve roles from serverMetadata or team permissions in context, not from the token alone
Firebase AuthenticationsubjectFromJwt against Google's securetoken JWKS, iss https://securetoken.google.com/<project>, aud the project idCustom claims set by the Admin SDK (setCustomUserClaims), for example roles, tenantdisplayName, photoURL, email until verifiedclaims: { id: 'sub', roles: 'roles', tenant: 'tenant' }; custom claims are server-only by construction
Google Identity (Sign in with Google, Google Workspace as OIDC IdP)subjectFromJwt against https://www.googleapis.com/oauth2/v3/certs, iss https://accounts.google.com, aud your OAuth client id; in practice the ID token is terminated by Better Auth, Auth.js, Clerk or WorkOS and PermDock reads their sessionNo roles claim at all; hd (hosted domain) is present only for Workspace accounts and is the tenant; group membership is not in the token and comes from the Admin SDK Directory API or Cloud Identity Groupsname, picture, email when email_verified is falseclaims: { id: 'sub', tenant: 'hd' }, roles from your database or a directory lookup in context; never the domain of email (single sign-on)
Amazon CognitosubjectFromJwt against https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/jwks.jsoncognito:groups in ID and access tokens; custom:* attributes in the ID tokenStandard attributes and any custom:* attribute the app client is allowed to writeclaims: { id: 'sub', roles: 'cognito:groups' }; only use a custom:* attribute for tenant if the app client has no write permission on it
KeycloaksubjectFromJwt against <realm>/protocol/openid-connect/certsrealm_access.roles, resource_access.<client>.roles; groups through a mapperUser attributes the account console lets users editclaims: { id: 'sub', roles: 'resource_access.<client>.roles' }; Keycloak also speaks AuthZEN and SSF, so it can be a PDP or a CAEP transmitter for the same deployment
Ory (Kratos, Hydra)Kratos whoami session in the framework; Hydra access tokens with subjectFromJwt against Hydra's /.well-known/jwks.jsonmetadata_admin on the identity (server-only); Ory Permissions is a separate relation graph reached through permdock/pdp if usedIdentity traits; metadata_public is server-written but client-visibleMap metadata_admin.roles in the session resolver; never traits

Three patterns recur. Every provider has a server-only bucket (app_metadata, custom claims, serverMetadata, metadata_admin, trusted_metadata) and a user-writable one; only the first feeds grants. Organisation-scoped roles ride in the token for the org the session is currently in, so multi-org checks need either a token per org or a context lookup. And a provider's own permission or feature-flag arrays are delegation.scopes or context inputs, not PermDock grants: the policy stays the single place that says who may do what.

Two newer rows follow from the 2026-07-28 MCP release. Providers that sell the OAuth 2.1 authorization server for MCP servers (WorkOS Connect, Stytch Connected Apps, Descope, Scalekit, Auth0 Token Vault, Supabase's OAuth server) issue the access tokens permdock/mcp receives as authInfo; client_id becomes actor.id and scope becomes delegation.scopes with no provider-specific code (MCP authorization). Enterprise agent identity (Okta Cross App Access, Microsoft Entra Agent ID) produces tokens with the human as sub and the agent as the client, which is the two-principal subject as-is (delegation). Billing and entitlement claims (Clerk Billing pla and fea, Frontegg entitlements, Kinde feature_flags) are server-written and eligible as roles under the "entitlements are roles" rule, never as grants (Clerk provider).

Claim trust rules

Verification tells you a token is genuine. It does not tell you which claims inside it may drive grants. PermDock's providers apply these rules, and your own subject resolver should too:

  • sub is the principal id. Nothing else (an email, a display name) identifies the principal, because those can change or be reused.
  • Server-set claims are trusted; user-editable claims are never used for grants. Supabase separates app_metadata (written by service code and Auth Hooks) from user_metadata (writable by the user through the client SDK). subjectFromSupabase reads roles and tenant from app_metadata or from a hook-injected top-level claim and never looks at user_metadata. The same split exists elsewhere under other names: Clerk public metadata set through the backend API versus anything the client can write; Better Auth user.role managed by the admin plugin versus profile fields.
  • Roles from claims or roles from the database. A role claim is cheap and offline-checkable but stale until the token is refreshed. A database lookup in the policy's context function is fresh but costs a query per createPermDock. Prefer claims when the token lifetime is short or a Shared Signals receiver invalidates on change; prefer context when role changes must take effect on the next request and tokens live for hours.
  • Tenant from claims. A tenant_id or orgId that RLS also filters on belongs in the principal, sourced from a server-set claim, so the in-process condition and the generated (select auth.jwt()) ->> 'tenant_id' read the same value. With scoped roles the claim fills principal.tenant (the active tenant) and a membership; it is never defaulted when absent (tenancy).
  • Memberships are verified material too. A team id, a resource share or a per-tenant role list comes from the provider's session, a server-set claim, a MembershipSource over your tables or the policy's own functions; never from a request body, a model argument, an unsigned header or a CLI flag. Team and group identifiers are ids (SCIM value, Entra object id), never display names.
  • Unknown role names are dropped. A claim naming a role the policy does not declare contributes nothing (fewer grants, never more) and is reported in development. On a membership the name is first offered to the tenant's RoleSource as a custom role; if it resolves, the declared roles it includes apply.

Single sign-on and directories

"Log in with Google Workspace", "log in with Microsoft" and SAML SSO through Okta are authentication, and PermDock stays out of them. The auth layer (Better Auth's SSO plugin, Clerk Enterprise SSO, WorkOS AuthKit, Auth.js providers, Supabase SAML) terminates the SAML assertion or the IdP's ID token and issues its own session or JWT; that is what subjectFrom* reads. A SAML assertion never reaches PermDock, and a Google or Entra ID token only does when your API accepts it directly as a bearer (the Google and Entra rows above).

Directory Sync, SCIM Identity providerGoogle Workspace, Entra ID, Okta(OIDC or SAML) Auth layerBetter Auth SSO, Clerk Enterprise SSO,WorkOS AuthKit, Auth.js, Supabase SAML Session or JWTverified claims subjectFrom*principal, tenant, roles PermDock decide Your databasegroups, role assignments

SSO nevertheless touches PermDock in three places, and each has a trap worth naming.

Tenant. The organisation a user logged in through is the natural principal.tenant, and every IdP carries it differently: Google Workspace in hd (hosted domain), Entra ID in tid, Okta in the authorization server's issuer or a custom claim, WorkOS and Clerk in org_id / orgId after they have mapped the connection to an organisation. Two rules apply. hd is absent for consumer Google accounts and tid is the personal-accounts tenant when the common endpoint is used, so a missing or unexpected tenant claim must produce a subject with no tenant (which tenant-scoped conditions then deny), never a default tenant; compare the claim against the connections you have onboarded, or restrict the issuer to your own Entra tenant and pass hd as an OAuth parameter to Google so the IdP filters first. And the domain of email is never a tenant: email_verified may be false, and a consumer IdP lets a user change the address. permdock doctor flags a claims.tenant that points at email (PD010) or at an optional tenant claim under a multi-tenant issuer (PD011).

Groups to roles. This is the permission-adjacent part and the IdPs differ most here:

IdPWhere groups areRecommended path to PermDock roles
Google WorkspaceNot in the ID token. Membership comes from the Admin SDK Directory API or the Cloud Identity Groups API, or from an auth layer that syncs it (WorkOS Directory Sync, Better Auth's SSO plugin with a provisioning hook)A role assignment table in your database populated by the sync, read in the policy's context or the session resolver; hd for the tenant
Microsoft Entra IDgroups as object ids in the token; above roughly 200 groups the claim is replaced by a _claim_names / _claim_sources overflow that must be resolved through Microsoft Graph; app roles (roles) carry admin-assigned role names insteadApp roles: the admin maps groups to roles in Entra and the token carries role names, so claims: { roles: 'roles' } is the whole mapping. Use groups only when app roles are not available, and map object ids, never display names
Oktagroups only when a groups claim filter is configured on the custom authorization server; otherwise absentConfigure the filter to emit the groups your policy declares, then claims: { roles: 'groups' }; unknown names are dropped by the trust rules above
SAML through the auth layer (any IdP)SAML attributes the auth layer maps to its own user or organisation fieldsRead the auth layer's role or membership field (Clerk orgRole, WorkOS role, Better Auth organization membership), never a raw attribute

The pattern that fits PermDock is to map groups to roles in the IdP or the auth layer, not in the policy: the policy declares roles, the token or session names them, and a directory group is one more server-set source of a role name. When the token only carries group ids, the mapping is a lookup in definePolicy's subject function and needs no PermDock API:

const rolesByGroupId: Record<string, string> = {
  'f2a1c0c8-1d5b-4b7e-9c0a-0d5b8a7c6e21': 'editor',   // Entra group object id, not the display name
  '0e6f5c1a-3b2d-4a9e-8f7c-1a2b3c4d5e6f': 'admin',
}

export const policy = definePolicy(permissions, {
  roles: [editor, admin],
  subject: (claims: EntraClaims | null) =>
    claims && {
      id: claims.oid,
      orgId: claims.tid,
      roles: (claims.groups ?? []).flatMap((id) => rolesByGroupId[id] ?? []),
    },
})

Group ids rather than display names, because names are editable by any group owner and are not unique across tenants. Directory Sync and SCIM are the third path: the IdP provisions users and groups into your database, and roles come from a row you control; IPSIE AL1 tracks the SCIM profile, and PermDock does not read SCIM itself.

With scoped roles the same lookup produces memberships instead of flat roles, which keeps the team visible to audit and to team-scoped roles: groups become { tenant: claims.tid, team: id, roles: rolesByGroupId[id] ?? [], via: 'group:' + id } entries, and subjectFromJwt does this by default for the RFC 9068 groups claim with a groupRoles option holding the id-to-roles map (JWT authorization claims).

Lifecycle. Deprovisioning a user in Google Workspace or Entra ID must reach cached snapshots. Entra, Okta and Auth0 transmit CAEP session-revoked and credential-change events today, which the SSF receiver turns into snapshot invalidation; for IdPs without a transmitter, the bound is exp and session_expiry as described under staleness, and SCIM deprovisioning into your database takes effect on the next context lookup. "We disabled the account and they can still see the page" is answered by whichever of these three you wired, so the SSO section of your runbook should name it.

JWT validation checklist

permdock/jwt implements this list; if you verify tokens yourself before calling a subjectFrom* function, your verifier must too. It follows RFC 8725 (JSON Web Token Best Current Practices) and its successor draft, rfc8725bis, which is in the RFC Editor queue.

  1. Allowed algorithms are an explicit list. Configure algorithms: ['ES256', 'PS256', 'EdDSA'] (or the subset your issuer uses). A token whose alg is not in the list is rejected before any key lookup.
  2. none is never allowed, whatever the list says.
  3. The token never picks the key. jku, x5u and embedded jwk headers are ignored; keys come only from the configured JWKS URL, key set or secret. This closes key-confusion attacks where an RSA public key is fed to an HMAC verifier.
  4. kid must resolve in the JWKS. An unknown kid triggers at most one refetch per configured interval; if it still does not resolve, the token is rejected.
  5. iss equals the configured issuer. Exact string comparison.
  6. aud contains this resource. A token issued for another API is rejected even when the signature is valid.
  7. exp and nbf are checked with a small, explicit clock tolerance (seconds, not minutes). iat in the future is rejected under the same tolerance.
  8. Signature before claims. Claims are parsed only after the signature verified; an attacker cannot make the verifier branch on unverified data.
  9. Bearer tokens come from the Authorization header (RFC 6750 section 2.1) or the DPoP scheme (RFC 9449 section 7.1). Tokens in query strings are rejected under profile: 'fapi2' and discouraged everywhere.

Token to delegation

A token often carries authority that was delegated to the bearer, not the bearer's own authority. PermDock maps the standard carriers to delegation so that the intersection rule applies:

ClaimStandardBecomes
scope (space-separated)RFC 6749 / RFC 8693delegation.scopes, matched against permission.scope
authorization_detailsRFC 9396 Rich Authorization Requestsdelegation.authorizationDetails, matched per permission by type
access (array of objects and reference strings)GNAP, RFC 9635 section 8delegation.access; objects match by type, actions, identifier; strings behave like scopes (GNAP)
act (nested actor claim)RFC 8693 section 4.1actor from the innermost act.sub; the full nesting as delegation.chain
may_actRFC 8693 section 4.4Recorded on the decision for audit; not a grant

A token with an act claim and no scope or authorization_details yields an actor with no delegation, and every check is denied with reason no-delegation. This is deliberate: a delegated token that says nothing about what was delegated grants nothing.

Sender constraint

FAPI 2.0 and the IPSIE profiles ask resource servers to accept sender-constrained tokens: a token bound to a key the client must prove it holds. The binding lives in the cnf claim and PermDock surfaces it on the subject so adapters can check it:

cnf memberStandardOn the subject
cnf.jktDPoP, RFC 9449binding: { method: 'dpop', thumbprint }
cnf.x5t#S256OAuth 2.0 mTLS, RFC 8705binding: { method: 'mtls', thumbprint }

The binding is attached to principal when the token identifies a user or workload acting for itself, and to actor when the token carries an act chain (the key belongs to the agent that presented it). Core stores it and puts it on audit events; checking it is an adapter concern, because proof-of-possession needs the request: verifyDpopProof(request, claims) in permdock/jwt compares the DPoP header's JWK thumbprint to cnf.jkt, and the mTLS check compares the client certificate the TLS terminator forwarded to cnf.x5t#S256. Under profile: 'fapi2' a token without cnf is rejected outright (FAPI 2.0).

Staleness and revocation

A verified token is a statement about the past. Three signals bound how long PermDock treats it as current:

  • exp. The subject built from a token inherits its expiry, and snapshot() sets expiresAt to it so a client stops trusting cached grants when the token would have expired.
  • session_expiry. The IPSIE SL1 profile and OpenID Connect Enterprise Extensions add a session_expiry claim to ID tokens and require re-authentication after it (IPSIE SL1 profile). When present it is the earlier bound: expiresAt = min(exp, session_expiry).
  • CAEP events. session-revoked, credential-change and assurance-level-change delivered through the Shared Signals Framework invalidate the subject's cached snapshots immediately, so revocation is bounded by the identity provider rather than by a timer (SSF adapter).

None of these make a stale token verify differently: an expired token is rejected by the checklist above, and a revoked but unexpired token is only caught by CAEP or by an introspection call your resolver chooses to make.

Authenticating the decision endpoint

The AuthZEN endpoint served by permdock/authzen answers "may this subject do this" for remote policy enforcement points. Two callers exist, and both must authenticate as themselves:

  • A PEP asking for the subject it authenticated (the browser's PermDockProvider endpoint, a gateway). The endpoint resolves the subject from the caller's own session or token and ignores any subject field in the request body. A browser can only ask about itself.
  • A trusted PEP asking on behalf of many subjects (an API gateway, a sidecar). It authenticates with client credentials or mTLS; the identity in that token must be in the endpoint's allow-list before the request body's subject is honoured.

A model, a tool argument or a page's JavaScript is never a trusted PEP: the threat model invariant "never trust model-supplied subjects" applies to the decision endpoint exactly as it does to MCP tools. A shared static secret in client code is obfuscation, not authentication (AuthZEN adapter).

Agent-run processes

CLIs, workers and agent runtimes are the place where verification is most tempting to skip. A command like mytool deploy --actor=ci-bot is a claim, not an identity. The terminal adapter therefore refuses to build a subject from flags or environment variables that name a principal or actor; it requires a verified token (device authorization grant, client credentials, or a workload identity) and derives the actor from it. Tokens stored on disk are treated like session cookies: short-lived, scoped, and invalidated by CAEP where the issuer supports it. An agent-run CLI that cannot present a token runs as anonymous and gets exactly the anonymous grants.

Example: a JWT into a decision

import { createPermDock } from 'permdock'
import { subjectFromJwt } from 'permdock/jwt'
import { policy } from './policy'
import { permissions } from './permissions'

const token = request.headers.get('authorization')?.replace(/^Bearer /, '')

const subject = await subjectFromJwt(token, {
  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', tenant: 'app_metadata.tenant' },
  delegation: { scopes: 'scope', authorizationDetails: 'authorization_details' },
  actor: { from: 'act' },
})
// subject = {
//   principal: { id: 'user_42', kind: 'user', roles: ['editor'], tenant: 'acme' } | null,
//   actor?:    { id: 'https://agent.example/cimd.json', kind: 'oauth-actor' },
//   delegation?: { scopes: ['post:read', 'post:update'], authorizationDetails: [...] },
//   expiresAt: 1757164800,
// }

const permdock = await createPermDock(policy, subject)
const decision = permdock.decide(permissions.post.update, post)
// granted: editor role allows it AND 'post:update' is in delegation.scopes
// denied { reason: 'not-delegated' }: the role allows it but the token did not delegate it
// denied { reason: 'anonymous' }: the token failed verification; the audit event carries the reason code

subjectFromJwt returned a full Subject, so createPermDock takes it as the second argument with no third; the actor and delegation are already inside it. When verification fails, subject.principal is null and the same two lines produce a denial rather than an exception.

  • Subject: the shape the verified material becomes.
  • JWT adapter: subjectFromJwt, createJwtSubjectResolver, verifyDpopProof.
  • Supabase provider: asymmetric signing keys, getClaims(), the Custom Access Token Hook.
  • Delegation: the attenuation invariants that the delegation mapping feeds.
  • SSF adapter: CAEP events from Entra, Okta and Auth0 that invalidate snapshots after SSO deprovisioning.
  • doctor: PD010 and PD011, the checks for role and tenant claims sourced from unverified or optional claims.
  • Threat model: token-handling threats and their mitigations.
  • ADR 0018: Authentication is upstream.

Sources

On this page