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
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(), Clerkauth(), the MCP SDK's bearer middleware). permdock/jwtverifies bearer JWTs against a JWKS or secret withjoseas 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
| Source | Verified by | Becomes | Adapter |
|---|---|---|---|
| Session cookie | The framework's session layer (Next.js, Better Auth server API) | principal from the session user | permdock/next, permdock/better-auth |
| Bearer JWT | Signature, iss, aud, exp, nbf against a JWKS or shared secret | principal 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 act | permdock/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 memberships | permdock/supabase (subjectFromSupabase) |
| Clerk session claims | Clerk middleware and auth() | principal from userId; tenant from the active orgId; a membership { tenant: orgId, roles: [orgRole] }; fea entitlement roles; custom claims through schema | permdock/clerk (subjectFromClerk) |
| Better Auth session | auth.api.getSession on the server | principal from the user; tenant from activeOrganizationId; memberships from member and teamMember rows; custom roles from organizationRole through a RoleSource | permdock/better-auth (subjectFromBetterAuth) |
MCP authInfo | The MCP SDK's bearer verification (requireBearerAuth, RFC 9207 iss check) | principal from the token's user; actor from clientId; delegation from scopes | permdock/mcp (subjectFromMcp) |
| API key | Constant-time lookup in your key store | A service principal: { id: keyOwnerId, kind: 'service', roles }, never an actor | Your subject resolver |
| Workload / service identity | Client-credentials token, WIMSE workload identity, SPIFFE ID presented over mTLS | principal.kind: 'workload', principal.id the SPIFFE ID or client id | permdock/jwt or your resolver |
| Web Bot Auth signature | RFC 9421 HTTP Message Signature against the Signature-Agent directory | actor { id: keyId, kind: 'web-bot-auth' }; the principal still comes from a token or session | Server kernel with webBotAuth |
| Transaction token | Signature by the Transaction Token Service, aud equal to your trust domain | principal from the token's subject, principal.context from azd, workload chain as actor | permdock/jwt |
| Anonymous | Nothing to verify, or verification failed | principal: null | Every 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.
| Provider | Verified by | Roles and organisation | User-editable, never grants | Recipe |
|---|---|---|---|---|
| Auth.js (NextAuth v5) | auth() in the framework; the JWT session strategy uses an encrypted JWE under AUTH_SECRET, not a public-key JWT | None built in: add role and orgId in the jwt and session callbacks from your database, never from the OAuth profile | Everything 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 Connect | subjectFromJwt 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 JWKS | org_id, role (organisation role slug), permissions array; managed in the WorkOS dashboard; Connect tokens carry client_id and scope | Profile fields | claims: { 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 registration | https://stytch.com/organization claim (organization_id, slug) and RBAC roles on B2B session and access tokens; scope for delegated apps | trusted_metadata is server-only, untrusted_metadata is member-editable | claims: { 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 |
| Descope | subjectFromJwt 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 vault | roles, permissions, and per-tenant tenants.<id>.roles on the session JWT | User-editable custom attributes if the app exposes them | claims: { id: 'sub', roles: 'roles' }, or tenants.<id>.roles for the active tenant; permissions can feed delegation.scopes |
| Scalekit | subjectFromJwt 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 scope | Profile fields | claims: { id: 'sub', roles: 'roles', tenant: 'oid' }; agent tokens fill actor and delegation through permdock/mcp |
| Okta (Workforce and Customer Identity), Okta Cross App Access | subjectFromJwt 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 IdP | groups claim (must be added to the access token), custom claims via claim expressions | Profile attributes | claims: { 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 ID | subjectFromJwt 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 own | roles (app roles), groups (or _claim_names overflow), tid; agent tokens carry the agent's oid and azp | None in the token; directory attributes are admin-set | claims: { 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 |
| Frontegg | subjectFromJwt against https://<subdomain>.frontegg.com/.well-known/jwks.json | roles, permissions, tenantId; Frontegg ships its own RBAC and entitlements (feature flags and plans) | metadata if the app lets users write it | claims: { 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 |
| PropelAuth | subjectFromJwt against https://<auth-url>/.well-known/jwks.json | org_id_to_org_member_info with user_role and user_permissions per organisation | metadata the user can edit through the hosted pages | Resolve the active organisation, then roles: [user_role]; user_permissions can feed delegation.scopes |
| SuperTokens, Hanko | subjectFromJwt 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 claim | Profile fields | SuperTokens claims: { id: 'sub', roles: 'st-role.v' }; Hanko resolves roles from your database in context |
| Auth0 | subjectFromJwt against https://<tenant>/.well-known/jwks.json | permissions (RBAC "add permissions in the access token"), org_id (Organizations), roles only through an Action writing a namespaced claim such as https://example.com/roles | user_metadata; app_metadata is server-only, the same split as Supabase | claims: { id: 'sub', roles: 'https://example.com/roles', tenant: 'org_id' }; never read user_metadata |
| Logto | subjectFromJwt against https://<tenant>.logto.app/oidc/jwks | roles for API-resource RBAC; organisation tokens carry organization_id and organisation roles; custom_data is admin-set | Profile and custom_data only if your app lets users write it | claims: { id: 'sub', roles: 'roles', tenant: 'organization_id' } with the organisation token as the bearer |
| Kinde | subjectFromJwt against https://<domain>.kinde.com/.well-known/jwks | permissions array, org_code, roles when enabled in token customisation, feature_flags | Profile fields | claims: { id: 'sub', roles: 'roles', tenant: 'org_code' }; treat feature_flags as context, not grants (policies) |
| Neon Auth | Better Auth hosted by Neon; JWKS published for Neon RLS | Same as permdock/better-auth: user.role from the admin plugin, organisation plugin membership | Profile fields | Use subjectFromBetterAuth server-side; for the database, the same JWT feeds auth.user_id() in generated RLS (RLS neon dialect) |
| Stack Auth | subjectFromJwt against https://api.stack-auth.com/api/v1/projects/<project-id>/.well-known/jwks.json | Team membership and team permissions from the server SDK; serverMetadata is server-only | clientMetadata; clientReadOnlyMetadata is server-written but client-visible | Resolve roles from serverMetadata or team permissions in context, not from the token alone |
| Firebase Authentication | subjectFromJwt against Google's securetoken JWKS, iss https://securetoken.google.com/<project>, aud the project id | Custom claims set by the Admin SDK (setCustomUserClaims), for example roles, tenant | displayName, photoURL, email until verified | claims: { 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 session | No 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 Groups | name, picture, email when email_verified is false | claims: { id: 'sub', tenant: 'hd' }, roles from your database or a directory lookup in context; never the domain of email (single sign-on) |
| Amazon Cognito | subjectFromJwt against https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/jwks.json | cognito:groups in ID and access tokens; custom:* attributes in the ID token | Standard attributes and any custom:* attribute the app client is allowed to write | claims: { id: 'sub', roles: 'cognito:groups' }; only use a custom:* attribute for tenant if the app client has no write permission on it |
| Keycloak | subjectFromJwt against <realm>/protocol/openid-connect/certs | realm_access.roles, resource_access.<client>.roles; groups through a mapper | User attributes the account console lets users edit | claims: { 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.json | metadata_admin on the identity (server-only); Ory Permissions is a separate relation graph reached through permdock/pdp if used | Identity traits; metadata_public is server-written but client-visible | Map 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:
subis the principal id. Nothing else (anemail, 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) fromuser_metadata(writable by the user through the client SDK).subjectFromSupabasereads roles and tenant fromapp_metadataor from a hook-injected top-level claim and never looks atuser_metadata. The same split exists elsewhere under other names: Clerk public metadata set through the backend API versus anything the client can write; Better Authuser.rolemanaged 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
contextfunction is fresh but costs a query percreatePermDock. Prefer claims when the token lifetime is short or a Shared Signals receiver invalidates on change; prefercontextwhen role changes must take effect on the next request and tokens live for hours. - Tenant from claims. A
tenant_idororgIdthat 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 fillsprincipal.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
MembershipSourceover 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 (SCIMvalue, 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
RoleSourceas 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).
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:
| IdP | Where groups are | Recommended path to PermDock roles |
|---|---|---|
| Google Workspace | Not 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 ID | groups 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 instead | App 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 |
| Okta | groups only when a groups claim filter is configured on the custom authorization server; otherwise absent | Configure 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 fields | Read 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.
- Allowed algorithms are an explicit list. Configure
algorithms: ['ES256', 'PS256', 'EdDSA'](or the subset your issuer uses). A token whosealgis not in the list is rejected before any key lookup. noneis never allowed, whatever the list says.- The token never picks the key.
jku,x5uand embeddedjwkheaders 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. kidmust resolve in the JWKS. An unknownkidtriggers at most one refetch per configured interval; if it still does not resolve, the token is rejected.issequals the configured issuer. Exact string comparison.audcontains this resource. A token issued for another API is rejected even when the signature is valid.expandnbfare checked with a small, explicit clock tolerance (seconds, not minutes).iatin the future is rejected under the same tolerance.- Signature before claims. Claims are parsed only after the signature verified; an attacker cannot make the verifier branch on unverified data.
- Bearer tokens come from the
Authorizationheader (RFC 6750 section 2.1) or theDPoPscheme (RFC 9449 section 7.1). Tokens in query strings are rejected underprofile: '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:
| Claim | Standard | Becomes |
|---|---|---|
scope (space-separated) | RFC 6749 / RFC 8693 | delegation.scopes, matched against permission.scope |
authorization_details | RFC 9396 Rich Authorization Requests | delegation.authorizationDetails, matched per permission by type |
access (array of objects and reference strings) | GNAP, RFC 9635 section 8 | delegation.access; objects match by type, actions, identifier; strings behave like scopes (GNAP) |
act (nested actor claim) | RFC 8693 section 4.1 | actor from the innermost act.sub; the full nesting as delegation.chain |
may_act | RFC 8693 section 4.4 | Recorded 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 member | Standard | On the subject |
|---|---|---|
cnf.jkt | DPoP, RFC 9449 | binding: { method: 'dpop', thumbprint } |
cnf.x5t#S256 | OAuth 2.0 mTLS, RFC 8705 | binding: { 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, andsnapshot()setsexpiresAtto 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 asession_expiryclaim 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-changeandassurance-level-changedelivered 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
PermDockProviderendpoint, a gateway). The endpoint resolves the subject from the caller's own session or token and ignores anysubjectfield 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
subjectis 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 codesubjectFromJwt 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.
Related
- 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
delegationmapping feeds. - SSF adapter: CAEP events from Entra, Okta and Auth0 that invalidate snapshots after SSO deprovisioning.
- doctor:
PD010andPD011, 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
- FAPI 2.0 Security Profile, sections 5.3.4 (resource servers) and 5.4.1 (cryptography).
- RFC 9635, Grant Negotiation and Authorization Protocol, section 8 (resource access rights).
- OAuth Transaction Tokens and the WIMSE architecture for workload principals and context propagation.
- IPSIE SL1 OpenID Connect Profile for
session_expiryand sender-constrained tokens. - Supabase JWT signing keys for the
app_metadata/user_metadatasplit andgetClaims(). - Google Identity: OpenID Connect for the
hdclaim, thehdauthorization parameter and the certificate endpoint. - Microsoft Entra: configure group claims and app roles for the
groupsoverflow behaviour and the app-roles alternative. - Okta: add a groups claim for the groups claim filter on custom authorization servers.
Tenants, teams and scoped roles
Memberships put roles in a scope (a tenant, a team, one resource); scoped role declarations apply grants only inside that scope; tenant-defined custom roles compose declared roles and never widen them; RoleSource and MembershipSource are the only new inputs.
Decisions
decide returns a discriminated Decision with three outcomes, matched grants, denials, alternatives and a replay-safe token; assert and simulate build on it.