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. The only selector that still matches is anyone(); every other to: fails closed. Nothing throws, nothing falls back to "trust the claims anyway", and the audit event says why.
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, 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 |
OAuth 2.0 / OIDC access token (bearer or DPoP JWT, typ: at+jwt or JWT) | Signature, iss, aud, exp, nbf, typ against the JWKS named by OIDC Discovery (discovery) or a configured jwks | principal from sub and issuer from iss; assurance from acr / amr / auth_time; session from sid; global roles from RFC 9068 roles, team memberships from groups, principal.plans from entitlements (JWT authorization claims); tenant from the configured claim; delegation from scope, authorization_details, GNAP access; actor from act; binding from cnf | permdock/jwt (subjectFromJwt) |
OIDC ID token (accept: 'id-token', explicit opt-in for BFF patterns) | As above, plus aud must equal the client id and nonce is verified by the RP, not by PermDock | Same principal fields; never delegation (an ID token carries no scope for a resource server) | permdock/jwt (subjectFromJwt) |
| GNAP or OAuth introspection response | The RS's own credential at the AS introspection endpoint (RFC 7662 / RFC 9767); the response is trusted as server-to-server material | principal from sub, issuer from iss; delegation.access from access or delegation.scopes from scope; binding from key | permdock/jwt (subjectFromIntrospection) |
Supabase getClaims() output | @supabase/supabase-js against the project JWKS (asymmetric keys) or the Auth server (legacy secret) | principal.id from sub, issuer from iss, roles and tenant from app_metadata / hook claim, assurance.acr from aal; memberships from your tables through memberships | permdock/supabase (subjectFromSupabase) |
Supabase @supabase/server jwtClaims (withSupabase context, or withClaims in a @supabase/middleware pipeline, also behind a framework bridge) | @supabase/server against the project JWKS; asymmetric keys only, HS256 rejected; null for no token or an API-key auth mode | Same fields as the getClaims() row; null is the anonymous subject | permdock/supabase (subjectFromSupabase) inside the adapter's subject callback, or permdock/supabase/middleware (withPermDock) in the pipeline |
Supabase named secret key (apikey: sb_secret_… matched by withSupabase, authMode: 'secret' with authKeyName) | @supabase/server against SUPABASE_SECRET_KEYS in constant time | Only a key named in the code-declared secretKeys map: { id, kind: 'service', tenant } whose only membership is { tenant, roles, via: 'credential' }, with delegation scopes for the entry's permissions. Any other key, and every publishable key, is the anonymous subject | permdock/supabase/middleware (createPermDock({ secretKeys })) |
A verified Supabase session object shaped { kind, claims } (for example better-supabase's AuthSession) | The session library, locally against the project JWKS | Same fields as the getClaims() row; any kind other than user is the anonymous subject, and with anonymousSignIns: 'deny' so is an is_anonymous user | permdock/supabase (subjectFromSupabaseSession) |
Supabase @supabase/ssr server client | The cookie-backed server client's getClaims() (never getSession() alone, which returns unverified material) | Same fields as the getClaims() row | permdock/next, permdock/server recipes with 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) |
Share link (permdock-capability+jwt) | Signature, typ, iss, aud, exp against your own signing key's JWK Set; sub bound to the capability id; revocation, one-time use and the redeemer checked by the resolver | A link principal whose only membership is { on, roles, via: 'link' }; delegation.scopes from the capability's permissions | permdock/jwt (subjectFromCapability) |
| CI job OIDC token (GitHub Actions, GitLab CI, Buildkite) | Signature, iss (the provider's issuer, or a self-managed issuer), aud, exp against the provider's JWKS; an optional schema over the claims | A workload principal: id from sub, issuer, provider, and repository, ref (a full refs/… ref) and environment from the provider's claims; never a user, never roles from the token | permdock/jwt (subjectFromCiOidc) |
API key (pdk_<id>_<secret><checksum>) | A CredentialVerifier: apiKeyVerifier finds the row by <id> and compares the key's SHA-256 hash in constant time; expiry, revocation and the tenant's credentials settings checked by the resolver | A user-bound key: its owner (live roles and memberships) with delegation scopes and RFC 9396 entries for the key's permissions. A service key: { id, kind: 'service', tenant } whose only membership is { tenant, roles, via: 'credential' }. Either carries principal.credential; never an actor (API keys) | permdock/server (subjectFromApiKey) |
| 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. Every mapper, PermDock's 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 principal.plans or context, 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 groups of rows concern agents. 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 are server-written plan and feature inputs, never grants: RFC 9068 entitlements and Clerk Billing pla become principal.plans, Clerk fea becomes roles through the features map, and Frontegg entitlements and Kinde feature_flags are principal.plans or context (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.
MFA and passkeys as policy input
assurance() on a grant reads principal.assurance. Providers fill that object from verified claims; they never invent an amr value the token did not carry.
| Source | Claim | Maps to | Notes |
|---|---|---|---|
| RFC 8176 | amr values pwd, otp, swk, hwk, mfa | principal.assurance.amr | hwk is a hardware key / passkey; mfa means more than one factor was used |
| OIDC Core | acr, auth_time | principal.assurance.acr, .authTime | Compared as an opaque string and a NumericDate |
| Supabase | aal (aal1, aal2) | principal.assurance.acr | aal2 is MFA completed. Supabase amr is an array of { method, timestamp } objects and is not copied into assurance.amr |
| Clerk | session fva / factorVerificationAge | Recipe: second element >= 0 means a second factor was verified | Clerk does not emit RFC 8176 amr. subjectFromClerk does not invent one |
| Better Auth | user.twoFactorEnabled plus a completed session | Recipe: a session exists only after TOTP / OTP / backup-code verify | The two-factor plugin stores no amr on the session. subjectFromBetterAuth does not invent one |
allow(permissions.filing.pay, {
to: [roles.admin, assurance({ amr: ["hwk"], maxAge: 300 })],
});A subject that lacks hwk or whose authTime is older than 300 seconds is denied with reason insufficient-user-authentication. HTTP adapters render Problem Details type ending in /step-up-required and WWW-Authenticate: Bearer error="insufficient_user_authentication".
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. Two ways to get there: an auth layer's Directory Sync (WorkOS, Better Auth's provisioning hook) writing rows your context or MembershipSource reads, or permdock/scim, an RFC 7644 receiver you mount that writes to a DirectoryStore you own and exposes it as directoryMembershipSource; group-to-role mapping is a groupRoles map or the roles attribute of the PermDock SCIM extension, set by the IdP or by the PermDock Cloud relay's mapping UI. The Cloud relays; it never resolves memberships (invariant 15). In Cloud-native directory mode the Cloud is the directory of record instead, and memberships reach your app only as the memberships, roles, entitlements and tenant claims of a Cloud-issued access token that subjectFromJwt verifies (PermDock Cloud). IPSIE AL1 is the checklist for the receiver's authentication.
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 (active: false or DELETE received by scimHandler, or a Directory Sync row) takes effect on the next directoryMembershipSource or context lookup, which is the next request when memberships are read per request and the next refetch when a snapshot is cached; scimHandler's onChange callback is where to invalidate the cache. "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.
Enterprise SSO
OIDC enterprise SSO is subjectFromJwt({ discovery }) against the IdP or the auth layer's issuer. SAML is terminated upstream: Better Auth's SSO plugin, Clerk Enterprise SSO, WorkOS AuthKit, Auth.js, Supabase SAML, or the Cloud gateway of PermDock Cloud. A SAML assertion is never a PermDock input, and there is no permdock/saml entry (ecosystem index).
JWT validation checklist
permdock/jwt implements the RFC 8725 checklist: keys and issuer from Discovery or explicit configuration, an explicit algorithm allow-list that never includes none, keys never chosen by the token, iss, aud, typ, exp and nbf checked, signature before claims, and every failure mapped to the anonymous subject with on('auth') reason invalid-token. If you verify tokens yourself before calling a subjectFrom* function, or plug a custom TokenVerifier in through the verifier option (extension interfaces), your verifier must follow the same list. The full checklist with its RFC mapping is on JOSE; the cause values are on the JWT adapter.
Token to delegation and sender constraint
A token often carries authority that was delegated to the bearer, not the bearer's own authority. subjectFromJwt maps scope (RFC 6749 / RFC 8693) to delegation.scopes, authorization_details (RFC 9396) to delegation.authorizationDetails, GNAP access (RFC 9635) to delegation.access, and the RFC 8693 act claim to actor with the full nesting as delegation.chain; may_act is recorded for audit and never grants. A sender-constrained token's cnf member (jkt for DPoP, x5t#S256 for mTLS) becomes binding on the principal or, for an act chain, on the actor. The shapes and the intersection rule are on subject.
Two rules belong to verification. A token with an act claim and no scope or authorization_details yields an actor with no delegation, so every check is denied with reason no-delegation. And checking a binding needs the request, so it is an adapter concern: verifyDpopProof(request, claims) in permdock/jwt compares the DPoP proof's JWK thumbprint to cnf.jkt, the mTLS check compares the forwarded client certificate to cnf.x5t#S256, and under profile: 'fapi2' a token without cnf is rejected (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 and Back-Channel Logout.
session-revoked,credential-changeandassurance-level-changedelivered through the Shared Signals Framework, and an OpenID Connectlogout_token(typ: logout+jwt) delivered by the OP, invalidate the subject's cached snapshots immediately, matched byissuer+principal.idor bysession(sid), so revocation is bounded by the identity provider rather than by a timer (SSF adapter, OpenID Connect).
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', issuer: 'https://login.example.com', kind: 'user', roles: ['editor'], tenant: 'acme' } | null,
// actor?: { id: 'https://agent.example/cimd.json', kind: 'oauth-client' }, // from `act`, RFC 8693
// 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.
- SCIM adapter: the RFC 7644 receiver that provisions users and groups into a
DirectoryStoreyou own and exposes them as memberships. - doctor:
PD010andPD011, the checks for role and tenant claims sourced from unverified or optional claims. - Threat model: token-handling threats and their mitigations.
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.
Last updated on
API keys and service accounts
Restricted credentials are opaque pdk_ keys stored as SHA-256 hashes; a user-bound key is its owner's live rights narrowed to the key, a service key is a service principal bounded by its creator, and tenant settings, revocation and events apply on every use.
Decisions
decide returns a discriminated Decision with three outcomes, matched grants, denials, alternatives and a replay-safe token; assert and simulate build on it.