Auth docs
Core concepts

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 unique
  • display_name — Human-readable label
  • status — active, suspended, or archived
  • metadata — JSON blob; Auth keeps the app's auth settings under the auth key

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 app
  • display_name — Human-readable label
  • status — active, suspended, or deactivated
  • contacts[] — One or more email/phone entries with verified_at; the primary email doubles as a signin identifier
  • metadata — 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)
  • memberuser.read and role.read only

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.

System catalog (shipped with every 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.create

Add 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 role claim. 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 scopes claim, 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), and org_id + org_role when signed into a tenant. Permissions are not on the token; resolve them from GET /me/permissions.
  • M2M tokens — issued via OAuth 2.0 client_credentials at POST /:appSlug/v1/oauth/token. Carry type: "m2m" and a flat scopes[] 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.

Example audit entry
{
  "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