Core concepts.
Auth is built around seven primitives. Understanding how they relate is the key to a clean integration.
Apps
An App is Auth's isolation boundary — one App per product (or per environment of a product). Your end-users, roles, and signing keys live inside it. If you're modelling B2B customers, those are Tenants inside the App, not separate Apps.
All data in Auth is scoped to an App. End-users, roles, permissions, tokens, and audit logs belong to exactly one App and are invisible to all others. Tokens are signed with per-app keys, so a token minted for one App can never verify against another.
When you create an App, three system roles for its end-users are provisioned automatically: owner, admin, and member. Managing the App itself (config, roles, credentials) happens through your ProductCraft workspace, not through an end-user account.
Key properties
id— Unique identifier (UUID)slug— URL-safe identifier, globally uniquedisplay_name— Human-readable labelstatus— active, suspended, or archivedmetadata— JSON blob; Auth keeps the app's auth settings under theauthkey
End Users
An End User is one of the people signing into your product through Auth. Each end-user account belongs to exactly one App — a person who happens to be a customer of two of your apps gets two distinct accounts. (This is a deliberate choice; multi-app identity belongs in a higher-level concept your product owns, not in Auth.)
Signup takes a username, an email, and a password. The email becomes the account's primary contact and receives a verification code. At signin the identifier is the username or the primary email — secondary contacts never authenticate. Each user can hold many contacts (email or phone), added through POST /:appSlug/v1/me/contacts. OAuth sign-in (Apple, Google, GitHub) and MFA are available per app.
Key properties
id— Unique identifier (UUID)username— Unique within the appdisplay_name— Human-readable labelstatus— active, suspended, or deactivatedcontacts[]— One or more email/phone entries withverified_at; the primary email doubles as a signin identifiermetadata— Arbitrary JSON for your application-specific data
Roles
A Role is a named collection of permissions within an App. Instead of assigning individual permissions to each user, you define roles (like “admin”, “editor”, “viewer”) and assign those roles to users.
Roles are App-scoped. An “admin” role in one App is completely independent from an “admin” role in another. Each user holds exactly one role per App, and their effective permissions are the permissions of that role.
Every App comes with three system roles that cannot be deleted. Their default grants (editable per app):
- owner — every permission in the app's catalog
- admin — manage users and role assignment (
user.read/list/update,role.read/assign/revoke) - member —
user.readandrole.readonly
You can create additional custom roles with any combination of the available permissions.
Permissions
Permissions are strings of the form resource.action. Auth ships a small system catalog and you can add app-specific entries on top of it. All entries are scoped to the App.
# User management
user.create user.read user.update user.delete user.list
# Role management
role.create role.read role.update role.delete role.assign role.revoke
# Custom permission management
permission.create permission.read permission.delete
# Sessions and tokens
session.revoke
token.createAdd custom entries through the console — open the app and go to Identity → Permissions, then Create custom permission (a resource + action pair) — or via the API (POST /v1/apps/:appId/permissions) — for example, document.read or billing.invoice.refund. Bind both system and custom entries to roles under Identity → Roles (each role's Permissions button opens the binding panel).
Two enforcement lanes — one for end-users, one for M2M
Both lanes check the same catalog from the same per-app guard. The token type decides where the perm comes from:
- End-user tokens carry a
roleclaim. The guard resolves the role's permission set per request through a 60-second LRU and checks membership. Permissions are deliberately not baked into the JWT — role-permission edits propagate within ≤60 s without forcing a refresh, and the token stays compact. - M2M tokens carry a flat
scopesclaim, set when the credential was minted. The guard checks scope membership directly.
Admin surface — same auth, both token types
The Consumer API exposes an admin lane at /:appSlug/v1/admin/users/* that accepts either an end-user token (your signed-in admin user) or an M2M token (a backend cron or service). Both lanes go through the same@RequireAppPermission guard — an M2M token with user.list scope and an end-user whose role grants user.list see the same response shape on GET /:appSlug/v1/admin/users.
Token introspection
POST /:appSlug/v1/oauth/introspect with Authorization: Bearer <token> returns { active, sub, type, exp, iat, iss, aid } per RFC 7662 — plus scopes, scope, and client_id for M2M tokens. M2M services use it to confirm their own scopes without decoding the JWT by hand. The endpoint refuses cross-token introspection (a body token that doesn't match the bearer just returns { active: false }) so a stolen token can't be probed against another active credential.
Enforcement is opt-in per app
New apps default to enforce_app_permissions: false — Consumer-API routes accept any valid token regardless of the perm annotation. Once you've authored your roles, flip it to true via PATCH /v1/apps/:appId/auth-config (or the console's Settings → Auth config, where it appears under Sign-in policy as the Enforce per-app permissions toggle). The change propagates within ~60 seconds.
Reading the effective set
For UI gating in your product, call GET /:appSlug/v1/me/permissions — it returns the union of the end-user's app-level role and (when the user is signed into a tenant) their tenant-level role. Don't decode the access token looking for a flat list; there isn't one.
Tokens
Auth issues JWTs signed with RS256 by per-app keys. Every token carries claims about the authenticated entity (end-user or service principal). Two flavours:
- End-user tokens — issued via signup, signin, or refresh. Carry the user's
sub,aid(app id),role,sid(session), andorg_id+org_rolewhen signed into a tenant. Permissions are not on the token; resolve them fromGET /me/permissions. - M2M tokens — issued via OAuth 2.0
client_credentialsatPOST /:appSlug/v1/oauth/token. Carrytype: "m2m"and a flatscopes[]claim drawn from the M2M client's configured scope set. Scopes are present in the token (no per-request resolver needed) because they're explicitly chosen by the customer at credential creation, not inherited from a default role.
Access tokens expire after 1 hour. Refresh tokens follow the per-app session duration (default 7 days). Verify tokens locally with the per-app JWKS at GET /:appSlug/v1/.well-known/jwks.json.
Tenants (organizations)
Optional sub-tenant layer for B2B products. A Tenant is a container inside an App that groups end-users sharing data — your customer's “organization”, “workspace”, or “team”. The wire shape mirrors Auth0/Clerk/WorkOS — JWTs carry org_id and org_role claims, documented under that name on the API surface.
One end-user can be a member of many tenants in one app with a different tenant-scoped role per membership. Switching tenants is a token-rotation step (POST /:appSlug/v1/auth/switch-tenant) that re-issues the access + refresh tokens with the new claims.
Permission union: when an end-user is signed into a tenant and the route uses @RequireAppPermission, the guard merges the app-level role's permissions with the tenant-level role's permissions as a UNION. The tenant role grants in addition to the app role; it never restricts.
Audit logs
Every state-changing operation in Auth produces an audit log entry. Audit logs are immutable, App-scoped, and queryable via GET /v1/apps/:appId/audit-logs (cursor-paginated; filter by ?action= and ?actor_id=). Each entry records the actor, action, affected resource, and client IP address.
{
"id": "...",
"app_id": "...",
"actor_id": "...",
"actor_type": "end_user",
"action": "auth.contact_verification.completed",
"resource": "account_contact",
"resource_id": "...",
"metadata": {},
"ip": "203.0.113.42",
"created_at": "2026-05-10T15:28:26Z"
}How it all fits together
App (your product, isolated auth boundary)
├── End Users
│ ├── username + password
│ ├── contacts[] (email, phone)
│ └── one app-level Role
├── Tenants (optional B2B sub-orgs)
│ └── memberships[]
│ └── one tenant-level Role
├── Roles
│ └── permissions[] from the per-app catalog
├── M2M credentials
│ └── scopes[] from the same catalog
├── Sessions
└── Audit log