Core concepts.
Heimdall is built around six primitives. Understanding how they relate is the key to a clean integration.
Apps
An App is Heimdall's unit of tenancy. If you're building a SaaS product, each of your customers is an App. If you're building a platform, each workspace or organisation is an App.
All data in Heimdall is scoped to an App. Roles, permissions, tokens, and audit logs belong to exactly one App and are invisible to all others. This isolation is enforced automatically on every query.
When you create an App, three system roles are provisioned automatically: owner, admin, and member. The creator is assigned the owner role with full permissions.
Key properties
id— Unique identifier (UUID)slug— URL-safe identifier, globally uniquedisplay_name— Human-readable labelstatus— active, suspended, or archivedmetadata— Arbitrary JSON for your application-specific data
End Users
An End User is one of the people signing into your product through Heimdall. 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 Heimdall.)
End users authenticate with username + password and a verification code sent to a registered contact (email or phone). Each user can hold many contacts; one primary email is required at signup, additional contacts can be added through POST /:appSlug/v1/me/contacts. Only verified primary email contacts authenticate at signin time.
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 is the canonical credentialmetadata— 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:
- owner — full permissions on all resources
- admin — management permissions without destructive capabilities
- member — read-only access to basic information
You can create additional custom roles with any combination of the available permissions.
Permissions
Permissions are strings of the form resource.action. Heimdall 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
# Sessions and tokens
session.revoke
token.createAdd custom entries through the console Permissions tab 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 in the Roles tab.
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 /v1/admin/users.
Token introspection
POST /:appSlug/v1/oauth/introspect with Authorization: Bearer <token> returns { active, sub, scopes, exp, iat, iss, aid, type } per RFC 7662. M2M services use it to confirm their own scopes without decoding the JWT by hand. The endpoint refuses cross-token introspection (the body token field must match the bearer when present) 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. Flip to true in Settings → Auth config once you've authored your roles and want them to start gating routes. The console's permission editor doesn't require you to flip the flag; the flag only changes whether the @RequireAppPermission guard returns 403 or passes through.
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
Heimdall 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 30 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 Heimdall produces an audit log entry. Audit logs are immutable, App-scoped, and queryable through a dedicated API endpoint. 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