Auth

Signup, login, MFA, sessions, password and email lifecycle, and how an Application gets the per-app JWT it trusts. See Trust Model for what an Application does with that token once it has one.

  1. Native auth and per-Organization SSO
  2. Token types at a glance
  3. Endpoints
  4. The AuthSession response
  5. POST /auth/signup
  6. POST /auth/login
  7. Test-mode Users
    1. Lifetime
  8. POST /auth/mfa/verify
    1. POST /auth/mfa/passkey-options
  9. POST /auth/token/refresh
  10. POST /auth/logout
  11. Getting an app token
    1. The app token
    2. POST /auth/app-tokens
    3. The authorization endpoint
    4. POST /auth/oauth/authorization-codes
    5. POST /auth/oauth/token
  12. Email verification
    1. POST /auth/email/verify
    2. POST /auth/email/verify/resend
  13. Password lifecycle
    1. POST /auth/password/forgot
    2. POST /auth/password/reset
    3. POST /users/{id}/password
  14. Invitations
    1. POST /auth/invitations/accept
  15. MFA
    1. TOTP
    2. POST /users/{id}/mfa/totp — begin enrollment
    3. POST /users/{id}/mfa/totp/confirm
    4. POST /users/{id}/mfa/totp/disable
    5. DELETE /users/{id}/mfa/totp — support reset
    6. Passkeys
      1. POST /users/{id}/mfa/passkeys/registration-options
      2. POST /users/{id}/mfa/passkeys
      3. GET /users/{id}/mfa/passkeys
      4. PATCH /users/{id}/mfa/passkeys/{passkeyId}
      5. DELETE /users/{id}/mfa/passkeys/{passkeyId}
    7. Staff MFA
    8. Step-up
    9. POST /auth/reauthenticate
  16. Sessions
    1. GET /users/{id}/sessions
    2. DELETE /users/{id}/sessions/{sessionId}
    3. POST /users/{id}/sessions/revoke-all
  17. POST /auth/sso/{provider}/callback
  18. Signing keys (JWKS)
  19. Errors specific to this resource

Native auth and per-Organization SSO

Both, not either/or: native in-house auth, architected from the start for pluggable, per-Organization SSO.

  • Native email/password is owned here, not delegated. It’s a real password_hash and real sessions, live from Phase 1, with zero external integration needed: no Auth0/WorkOS/Cognito account, no third-party API key, nothing to configure. A single developer standing up their first Application has a fully working login system the moment Phase 1 ships.
  • SSO is a per-Organization bridge, not a platform-wide or per-Application choice. An Organization admin connects their own company’s identity provider (Okta, Azure AD, Google Workspace, …) through a federation broker (leaning WorkOS, for exactly this “bring your own enterprise IdP” case), and it becomes available to every member of that Organization. See Domain Model → SSOConnection.
  • A User can hold both at once, a password identity and an sso identity, and choose either at login. See Domain Model → UserIdentity.
  • MFA (TOTP or passkeys) is optional and user-enabled for native accounts, through self-service enrollment, and required for staff. It isn’t built for SSO identities, whose MFA policy belongs to the member’s own IdP.
  • Sequencing: the UserIdentity/SSOConnection schema and the auth endpoints are built in this shape now, so adding a real SSO broker later is additive, not a breaking migration. The broker integration itself is explicitly not being built yet. POST /auth/sso/{provider}/callback exists as a route and returns 501 not_implemented until it is.

How an Application obtains its per-app JWT is covered under Getting an app token: embedded login now, hosted authorization code + PKCE once Substratal’s hosted sign-in page exists. That hosted page is required before any third-party Application goes live.

Token types at a glance

Token Format TTL Issued by Used for
Platform access token JWT (RS256) 15 minutes login, mfa/verify, token/refresh, signup, invitations/accept Calling this API as a User (Authorization: Bearer …).
Refresh token Opaque, rtk_… 30 days idle, 90 days absolute Same as above Getting a new platform access token. Single-use, rotating.
App token JWT (RS256), aud = one application_id 5 minutes app-tokens, oauth/token Presented by a User to one Application, verified locally by that Application. See Trust Model.
App refresh token Opaque, rtk_…, bound to one Application 30 days idle, 90 days absolute oauth/token Getting a new app token without the Application ever holding a platform token (hosted flow only). It belongs to the session the User signed in with on the hosted page, so ending that session ends it too. Re-checks access every time.
API Key secret Opaque, satk_live_… / satk_test_… Until revoked API Keys Service-to-service calls. Not a User session.

Every platform access token and app token is signed with a key published at https://api.substratalapps.com/.well-known/jwks.json (see Signing keys). Every login creates a session (ses_…); refresh tokens and app refresh tokens belong to exactly one session, and revoking the session revokes all of them at once.

Endpoints

Method Path Auth Purpose
POST /auth/signup none Self-service account creation with email + password.
POST /auth/login none Exchange email + password for a session (or an MFA challenge).
POST /auth/mfa/verify none (mfa_token) Complete a login that returned an MFA challenge.
POST /auth/mfa/passkey-options none (mfa_token) WebAuthn request options to complete that challenge with a passkey.
POST /auth/token/refresh none (refresh_token) Rotate a platform refresh token for a new access token.
POST /auth/logout Bearer (user) Revoke the current session, or every session.
POST /auth/reauthenticate Bearer (user) Prove a second factor again, for step-up.
POST /auth/reauthenticate/passkey-options Bearer (user) WebAuthn request options to re-authenticate with a passkey.
POST /auth/app-tokens Bearer (user) Mint a per-app JWT from a platform session (embedded login).
POST /auth/oauth/authorization-codes Bearer (user) Mint a one-time authorization code for a hosted launch (PKCE).
POST /auth/oauth/token none (code / refresh token) Exchange an authorization code or app refresh token for an app token.
POST /auth/email/verify none (token) Confirm an email address from the link in a verification email.
POST /auth/email/verify/resend none Re-send a verification email. Always 202.
POST /auth/password/forgot none Send a password-reset email. Always 202.
POST /auth/password/reset none (token) Set a new password from a reset token.
POST /auth/invitations/accept none (token) Set a password on an invited account and activate it.
POST /auth/sso/{provider}/callback none Complete an SSO login. 501 until the broker integration ships.
POST /users/{id}/password Bearer (self) Change password, given the current one.
POST /users/{id}/mfa/totp Bearer (self) Begin TOTP enrollment.
POST /users/{id}/mfa/totp/confirm Bearer (self) Confirm enrollment with a first code; returns recovery codes.
POST /users/{id}/mfa/totp/disable Bearer (self) Turn TOTP off, given a current code or recovery code.
DELETE /users/{id}/mfa/totp Bearer (users.manage) Support reset for a user who lost their device: removes TOTP and passkeys.
POST /users/{id}/mfa/passkeys/registration-options Bearer (self) Begin registering a passkey.
POST /users/{id}/mfa/passkeys Bearer (self) Finish registering a passkey.
GET /users/{id}/mfa/passkeys Bearer (self or users.manage) List passkeys (no key material).
PATCH /users/{id}/mfa/passkeys/{passkeyId} Bearer (self) Rename a passkey.
DELETE /users/{id}/mfa/passkeys/{passkeyId} Bearer (self) Remove a passkey.
GET /users/{id}/sessions Bearer (self or users.manage) List active sessions.
DELETE /users/{id}/sessions/{sessionId} Bearer (self or users.manage) Revoke one session.
POST /users/{id}/sessions/revoke-all Bearer (self or users.manage) Revoke every session: sign out everywhere.
GET /.well-known/jwks.json none Public signing keys. No Substratal-Version needed.
GET /.well-known/openid-configuration none Issuer metadata (issuer, JWKS URI, token endpoint). No Substratal-Version needed.

All unauthenticated POST /auth/* endpoints are rate-limited per IP address as well as per account — see Non-Functional Requirements → Rate limiting.

The AuthSession response

Every endpoint that establishes or refreshes a platform session returns this shape:

{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNrXzIwMjYxMCJ9...",
  "expires_in": 900,
  "refresh_token": "rtk_01JAG8K4Q9R0S1T2U3V4W5X6Y7",
  "refresh_token_expires_in": 2592000,
  "session_id": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
  "user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "email_verified": true
}

The platform access token’s claims:

{
  "iss": "https://api.substratalapps.com",
  "aud": "substratal-platform",
  "sub": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "sid": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
  "amr": ["pwd", "otp"],
  "email_verified": true,
  "test_mode": false,
  "mfa_verified_at": 1730649600,
  "iat": 1730649600,
  "exp": 1730650500,
  "jti": "atk_01JAG8K5A1B2C3D4E5F6G7H8J9"
}

amr lists the authentication methods used (pwd, otp, hwk for a passkey, recovery_code, sso). mfa_verified_at (Unix seconds) is when the session last proved a second factor, at MFA sign-in, TOTP enrollment, passkey registration, or POST /auth/reauthenticate. It’s absent when the session never has. A client reads it to re-authenticate before a step-up operation rather than after its 403. The API itself checks the session, not the claim. The platform access token carries no permissions — the API resolves the caller’s Roles fresh on every request, so a Role change takes effect on the very next call, not at the next refresh.

POST /auth/signup

// Request
{
  "email": "jordan@example.com",
  "password": "correct horse battery staple",
  "display_name": "Jordan Alvarez",
  "application_id": "app_timetrack"
}
// Response — 201, an AuthSession
{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 900,
  "refresh_token": "rtk_01JAG8K4Q9R0S1T2U3V4W5X6Y7",
  "refresh_token_expires_in": 2592000,
  "session_id": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
  "user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "email_verified": false
}
  • Creates the User (status: active, email_verified: false), a password UserIdentity, a default Profile (with display_name if given) and Settings, and the member platform Role — one transaction, same as POST /users.
  • Sends a verification email (token TTL 24 hours). An unverified User can log in; email_verified is carried in every token so an Application can decide for itself whether to gate anything on it.
  • application_id is optional and grants nothing — it records which Application the signup came from (signup_application_id on the User, used to brand the verification email with that app’s email_from_name) and is otherwise informational. Access to an Application still requires an Entitlement, which the developer’s own backend grants (see Workflows → Purchase → access).
  • If the email belongs to an invited User, signup is rejected with 409 email_taken (details.reason: "invitation_pending"), and a fresh invitation email is sent to that address (this counts against the same 3-per-email-per-hour limit as email/verify/resend). An invitation can only be completed with its inv_ token, through invitations/accept, because whatever was already attached to the invited account (Entitlements, Roles, Organization memberships) must only go to someone who controls the mailbox. Completing it by signup would hand all of that to anyone who knew the address.
  • test_mode (optional, default false): true creates a test-mode User instead. A test-mode signup may also send expires_at to choose how long the account lasts (default 24 hours). See Test-mode Users.
  • While an operator has closed signup for that mode (Platform settings, signup_open: false), signup returns 403 signup_closed. Invitations, accepted through invitations/accept, still work.
  • Password policy: 12–128 characters; rejected if it appears in the bundled breached-password list (top 100,000) or equals the email address. No composition rules. Stored as Argon2id (m=19456 KiB, t=2, p=1).
Error Status When
email_taken 409 An active, suspended, or invited User already has this email in the same mode. For an invited User, details.reason is invitation_pending and the invitation is re-sent.
weak_password 422 Fails the password policy; details.reason is too_short, too_long, breached, or matches_email.
validation_failed 422 Malformed email, unknown application_id, etc.

POST /auth/login

// Request
{ "email": "jordan@example.com", "password": "correct horse battery staple" }
// Response — 200, no MFA enrolled: an AuthSession (see above)
// Response — 200, MFA enrolled: a challenge instead of a session
{
  "mfa_required": true,
  "mfa_token": "mfa_01JAG8M2N3P4Q5R6S7T8U9V0W1",
  "methods": ["totp", "passkey", "recovery_code"],
  "expires_in": 300
}

Clients must branch on mfa_required before reading access_token. methods says which second factors this User can use: totp when TOTP is on, passkey when they have at least one passkey, and always recovery_code. Add "test_mode": true to sign in a test-mode User; see Test-mode Users.

Error Status When
invalid_credentials 401 Unknown email, wrong password, an invited User with no password yet, or an SSO-only User (no password identity). Deliberately one code for all four — no account enumeration.
account_suspended 403 Correct credentials, but status: suspended.
test_user_expired 403 Correct credentials, but the test-mode User’s expires_at has passed. See Test-mode Users → Lifetime.
too_many_attempts 429 5 failed attempts against this account’s password identity in 15 minutes locks that identity for 15 minutes; Retry-After is set. Successful login resets the counter. (The counter lives on the UserIdentity, so a lockout never blocks the same User’s SSO login.)

Updates last_login_at on the User and last_used_at on the matching UserIdentity, when the session is actually issued (for an MFA login, at mfa/verify).

Test-mode Users

A User created by a test-mode API Key (or by signup with "test_mode": true) is a separate account from any live User with the same email, since uniqueness is per mode. The auth endpoints that look an account up by email therefore take an optional test_mode boolean, default false, and only ever match accounts in that mode:

Endpoint Field
POST /auth/signup test_mode creates the User in that mode.
POST /auth/login test_mode picks which account the email refers to.
POST /auth/password/forgot, POST /auth/email/verify/resend test_mode picks which account to email.

Endpoints that take a token (mfa/verify, token/refresh, password/reset, email/verify, invitations/accept, oauth/token) don’t need it, because the token already belongs to exactly one account. Every token issued to a test-mode User carries "test_mode": true, and the API confines that token to test-mode rows exactly as it does a satk_test_ key. Applications themselves are shared catalog rows, visible in both modes, so a test-mode User gets app tokens for the same Applications a live one does, as long as they hold a (test-mode) Entitlement to it.

Lifetime

Every test-mode User is time-limited. Its expires_at is set when it’s created, and at that moment the account stops working. Live Users never expire (expires_at is always null).

  • Default: 24 hours after creation. A caller creating a test-mode User, through POST /users with a satk_test_ key or through signup with "test_mode": true, may send expires_at to choose a different lifetime, to suit what’s being tested: a short-lived account for one CI run, or one that lasts a week for a manual QA pass.
  • Range: at least 15 minutes from now, and no later than 30 days after the User’s created_at, which is when its test data is purged. Anything outside that range is 422 validation_failed, with the expires_at field’s code expires_at_out_of_range in details.fields. Sending expires_at for a live User is 422 validation_failed with the code live_user.
  • Changing it: users.manage can move expires_at with PATCH /users/{id}, within the same range, to extend a run, end it early, or bring back an expired User that hasn’t been purged yet.
  • At expiry: login returns 403 test_user_expired, and every token issued to the User stops working, whatever its own exp. That covers access, refresh, and app tokens. No token is issued with an exp later than the User’s expires_at, so an Application that verifies app tokens locally against JWKS stops accepting them at the same moment. Within 5 minutes, the expiry sweep revokes the User’s sessions (revoked_reason: test_user_expired), deactivates their push devices, fires access.revoked (reason test_user_expired) for every Application they had active access to, and writes the user.expired Audit Event. Like suspension, expiry leaves status and the User’s Entitlements, Roles, and memberships unchanged, so an extension restores exactly what was there and fires access.granted again.
  • Purge: the nightly purge of test-mode rows older than 30 days is unchanged. The 30-day ceiling on expires_at means a test User has always expired before its data is purged.

Test-mode API Keys, Organizations, and Applications don’t expire. Only the accounts people or test suites sign in as do.

POST /auth/mfa/verify

// Request — a TOTP code
{ "mfa_token": "mfa_01JAG8M2N3P4Q5R6S7T8U9V0W1", "code": "492039" }
// Request — or a recovery code instead
{ "mfa_token": "mfa_01JAG8M2N3P4Q5R6S7T8U9V0W1", "recovery_code": "7hq2-kx9m-a4vd" }
// Request — or a passkey assertion, for options from POST /auth/mfa/passkey-options
{ "mfa_token": "mfa_01JAG8M2N3P4Q5R6S7T8U9V0W1", "passkey": { "id": "…", "rawId": "…", "type": "public-key", "response": { "clientDataJSON": "…", "authenticatorData": "…", "signature": "…", "userHandle": "…" } } }
// Response — 200, an AuthSession

Exactly one of code, recovery_code, or passkey. TOTP is RFC 6238, SHA-1, 6 digits, 30-second step, accepting ±1 step of clock drift; a code can’t be reused within its window. A recovery code is single-use. A passkey sign-in records amr: ["pwd", "hwk"]. The mfa_token is single-use and valid 5 minutes; 5 wrong codes or failed passkey responses invalidate it.

POST /auth/mfa/passkey-options

// Request
{ "mfa_token": "mfa_01JAG8M2N3P4Q5R6S7T8U9V0W1" }
// Response — 200
{
  "publicKey": {
    "challenge": "y3p0L2iJ0bQ9…",
    "timeout": 300000,
    "rpId": "substratalapps.com",
    "allowCredentials": [{ "type": "public-key", "id": "…", "transports": ["internal", "hybrid"] }],
    "userVerification": "required"
  },
  "expires_in": 300
}

PublicKeyCredentialRequestOptions for this User’s passkeys only. Hand publicKey to navigator.credentials.get() (or PublicKeyCredential.parseRequestOptionsFromJSON() first) and send the credential’s JSON as passkey to POST /auth/mfa/verify with the same mfa_token. The challenge is valid 5 minutes and works once. Errors: 401 mfa_token_invalid, 409 passkey_not_registered.

Error Status When
invalid_mfa_code 401 Wrong or reused code.
passkey_invalid 401 The passkey response didn’t verify: unknown credential, bad signature, wrong origin or relying party, a challenge that expired, was already used, or wasn’t issued for this mfa_token, or a sign-count regression.
mfa_token_invalid 401 Expired, already used, or exhausted. Start over at login.

POST /auth/token/refresh

// Request
{ "refresh_token": "rtk_01JAG8K4Q9R0S1T2U3V4W5X6Y7" }
// Response — 200, an AuthSession with a new access_token AND a new refresh_token

Refresh tokens are single-use. Presenting one that has already been rotated is treated as theft: the whole session is revoked, and every token in it stops working (401 refresh_token_reused). Refresh fails with 401 session_revoked after logout, password reset, suspension, or deletion.

POST /auth/logout

// Request — this session only (default)
{}
// Request — every session this User has, on every device
{ "all_sessions": true }
// Response — 204

Revokes the session(s): the refresh token and any app refresh tokens in them stop working immediately, and the platform access token is rejected from then on — the API checks sid against the session table on every request, so logout is immediate, not “at access-token expiry”. Already-issued app tokens are not recalled (they’re verified locally by Applications, by design); they expire within 5 minutes. Logout does not by itself fire access.revoked — the User still has access; they’ve just signed out. Push devices registered under a revoked session become inactive, so a signed-out install stops receiving notifications.

Getting an app token

Embedded login is allowed for Applications staff create, which are either Substratal’s own or a concierge customer’s, so no unvetted third party ever handles a Substratal password. A self-registered Application is third-party: app-tokens refuses it with 403 embedded_login_not_allowed, and it must use the hosted flow. The hosted sign-in page must exist before any self-registered Application can go live. Requiring the hosted page from Phase 1 instead was considered and not chosen: it would make the MVP depend on a UI project.

An app token is the per-Application JWT that Trust Model describes. Two ways to get one:

Path When Calls
Embedded The Application draws its own sign-in screen (any first-party app, native or web — the only option until the hosted page exists). login → app-tokens. Refresh the platform session with token/refresh, then call app-tokens again.
Hosted + PKCE The Application sends the user to Substratal’s hosted sign-in page (ships with the dashboard), and never sees the password. Required for third-party Applications. App opens the authorization endpoint → hosted page calls oauth/authorization-codes → app calls oauth/token. Refresh with oauth/token (grant_type: refresh_token).

Both paths produce the identical app token. Either way, issuance fails if the User doesn’t have active access to the Application right now:

Error Status When
entitlement_required 403 Resolved entitlement status isn’t active (see Access Control). details.entitlement_status says what it is, including scheduled for a grant whose starts_at hasn’t arrived.
embedded_login_not_allowed 403 app-tokens only: the Application is self_registered, so it must use the hosted flow.
application_not_available 409 The Application’s review_status is suspended/rejected/pending_review — see Applications → review lifecycle. A state conflict, not a permission failure, so it’s the same 409 wherever this code appears.
tenant_suspended 403 The Application’s Tenant is suspended by the platform. See Tenancy.
account_suspended 403 The User is suspended.
test_user_expired 403 The test-mode User’s expires_at has passed. See Test-mode Users → Lifetime.

The app token

{
  "iss": "https://api.substratalapps.com",
  "aud": "app_timetrack",
  "sub": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "sid": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
  "org_id": null,
  "email": "jordan@example.com",
  "email_verified": true,
  "entitlement_status": "active",
  "effective_permissions": ["app.timetrack.export", "app.timetrack.manage_members"],
  "test_mode": false,
  "iat": 1730649600,
  "exp": 1730649900,
  "jti": "apt_01JAG8P1Q2R3S4T5U6V7W8X9Y0"
}
  • effective_permissions contains only this Application’s own app.<slug>.* keys — never platform permissions. See Access Control → Step 2.
  • org_id is the Organization whose org-wide grant provided access, if access came only through an Organization; null if the User has a personal Entitlement (personal wins for attribution when both exist). If multiple Organizations provide access and there’s no personal path, it’s the one whose grant was created first. Pass organization_id to app-tokens to pick a specific one explicitly.
  • sid lets an Application key its own local session to the hub session, so that an access.revoked webhook or a logout-everywhere can be matched to the right local session.

POST /auth/app-tokens

// Request
{ "application_id": "app_timetrack" }
// Response — 200
{
  "token_type": "Bearer",
  "app_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNrXzIwMjYxMCJ9...",
  "expires_in": 300,
  "application_id": "app_timetrack"
}

Bearer must be a platform access token (a User), not an API Key — 400 user_token_required otherwise. Optional organization_id selects which Organization’s grant to attribute org_id to; 403 entitlement_required if that Organization doesn’t actually include this User.

The authorization endpoint

The hosted flow starts by sending the User’s browser (or, for a native app, the system browser) to the hosted sign-in page, a standard OAuth 2.0 authorization endpoint:

GET https://auth.substratalapps.com/authorize
  ?response_type=code
  &client_id=app_timetrack
  &redirect_uri=com.substratal.timetrack%3A%2Foauth%2Fcallback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=af0ifjsldkj

It’s a web page, not part of the REST API, and it’s listed as authorization_endpoint in openid-configuration, so libraries like AppAuth discover it. The page signs the User in (creating a platform session if there isn’t one), then calls oauth/authorization-codes below with the same parameters and redirects to redirect_to. If client_id or redirect_uri isn’t valid it shows an error instead of redirecting, per OAuth 2.0; any later failure (no access, User cancelled) redirects back with error=access_denied and the original state. The page ships with the dashboard; until then only embedded login is available.

POST /auth/oauth/authorization-codes

Called by Substratal’s hosted sign-in page (or the future dashboard’s “Launch” button) on the signed-in User’s behalf — not by the Application.

// Request
{
  "application_id": "app_timetrack",
  "redirect_uri": "com.substratal.timetrack:/oauth/callback",
  "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "code_challenge_method": "S256",
  "state": "af0ifjsldkj"
}
// Response — 201
{
  "code": "ac_01JAG8R4S5T6U7V8W9X0Y1Z2A3",
  "expires_in": 60,
  "redirect_to": "com.substratal.timetrack:/oauth/callback?code=ac_01JAG8R4S5T6U7V8W9X0Y1Z2A3&state=af0ifjsldkj"
}

redirect_uri must exactly match one of the Application’s registered redirect_uris (422 redirect_uri_not_registered); code_challenge_method must be S256 (plain is rejected, 422 validation_failed). An unknown or invisible application_id returns 404 application_not_found. The code is single-use, expires in 60 seconds, and belongs to the caller’s session: the app refresh token it’s later exchanged for is revoked whenever that session is. Access is checked here too, with the same errors as above, so a User without access is never redirected into the app holding a code.

POST /auth/oauth/token

Called by the Application. A public-client endpoint: there’s no client secret, the PKCE verifier is the proof. It’s the one Auth endpoint that doesn’t need Substratal-Version, because standard OAuth libraries call it without custom headers (see Conventions → Versioning). Sending the header is fine.

// Request — exchange an authorization code
{
  "grant_type": "authorization_code",
  "client_id": "app_timetrack",
  "code": "ac_01JAG8R4S5T6U7V8W9X0Y1Z2A3",
  "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
  "redirect_uri": "com.substratal.timetrack:/oauth/callback"
}
// Request — refresh
{
  "grant_type": "refresh_token",
  "client_id": "app_timetrack",
  "refresh_token": "rtk_01JAG8S5T6U7V8W9X0Y1Z2A3B4"
}
// Response — 200
{
  "token_type": "Bearer",
  "app_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 300,
  "refresh_token": "rtk_01JAG8T6U7V8W9X0Y1Z2A3B4C5",
  "refresh_token_expires_in": 2592000,
  "application_id": "app_timetrack"
}

Accepts application/json and, for compatibility with standard OAuth client libraries (AppAuth, etc.), application/x-www-form-urlencoded. Errors on this one endpoint use the OAuth 2.0 shape ({"error": "invalid_grant", "error_description": "..."}) instead of this API’s usual envelope, again for library compatibility: invalid_grant (bad/expired/reused code or verifier mismatch), invalid_client (unknown client_id), unsupported_grant_type, and access_denied (access no longer active — error_description carries the entitlement_required detail). A refresh re-checks access every time — a refresh after revocation fails, which is what makes the hosted flow safe without a platform session.

Email verification

Transactional email (verification, password reset, invitations, MFA changes) is sent through Amazon SES in the same AWS account, so it adds no new sub-processor. Templates are Substratal-branded; an Application can set email_from_name and support_url so a message triggered from inside it names the app. A full per-Application custom domain and template is deferred.

POST /auth/email/verify

// Request — token from the emailed link
{ "token": "emv_01JAG8V8W9X0Y1Z2A3B4C5D6E7" }
// Response — 204

Sets email_verified: true (or, for a pending email change, swaps pending_email into email — see Users → PATCH). Token: single-use, 24-hour TTL. 400 token_invalid if expired, used, or unknown.

POST /auth/email/verify/resend

// Request
{ "email": "jordan@example.com" }
// Response — 202, always, whether or not the email exists or is already verified

Limited to 3 per email per hour.

Password lifecycle

POST /auth/password/forgot

// Request
{ "email": "jordan@example.com" }
// Response — 202, always — never reveals whether the account exists

Sends a reset link (token pwr_…, single-use, 1-hour TTL) to an active User with a password identity. An SSO-only User gets an email explaining they sign in through their organization instead. Limited to 3 per email per hour.

POST /auth/password/reset

// Request
{ "token": "pwr_01JAG8W9X0Y1Z2A3B4C5D6E7F8", "new_password": "a new long passphrase" }
// Response — 204

Sets the new password (same policy as signup), marks the email verified (the User just proved control of it), revokes every session, and emails a “your password was changed” notice. Writes Audit Event user.password_reset. Errors: 400 token_invalid, 422 weak_password.

POST /users/{id}/password

// Request
{ "current_password": "correct horse battery staple", "new_password": "a new long passphrase" }
// Response — 204

Self only (an admin cannot set a User’s password — they can only trigger password/forgot on the User’s behalf). Revokes every other session; the calling session survives. Errors: 401 invalid_credentials (wrong current password), 422 weak_password. Writes user.password_changed.

Invitations

POST /users with status: "invited" (the default — see Users) or POST /organizations/{id}/members with an unknown email creates an invited User and emails an invitation link (token inv_…, single-use, 7-day TTL).

POST /auth/invitations/accept

// Request
{ "token": "inv_01JAG8X0Y1Z2A3B4C5D6E7F8G9", "password": "correct horse battery staple", "display_name": "Jordan Alvarez" }
// Response — 200, an AuthSession

Creates the password identity, sets status: active and email_verified: true, and signs the User in. If the invitation came from an Organization, accepting it also accepts that pending membership (and any other pending memberships for this User), the same as …/members/me/accept. Errors: 400 token_invalid (expired invitations are re-sent with POST /users/{id}/invitation — see Users), 422 weak_password.

MFA

Two-step verification for password identities, with two kinds of second factor: TOTP (an authenticator app) and passkeys (WebAuthn). A User has MFA (mfa_enabled: true) when they have TOTP or at least one passkey, and either is accepted at sign-in and for step-up. Passkeys resist phishing, so they’re the recommended method for staff. Optional for most Users, required for staff. Meaningless for a method: sso identity, whose MFA policy belongs to the Organization’s own IdP (409 mfa_not_applicable if the User has no password identity).

Recovery codes come with the User’s first MFA method, TOTP or passkey: ten single-use codes, shown once, stored hashed. A later method keeps that set. They’re deleted when the User’s last method goes.

TOTP

POST /users/{id}/mfa/totp — begin enrollment

// Request
{}
// Response — 200
{
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauth_uri": "otpauth://totp/Substratal:jordan%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Substratal&digits=6&period=30",
  "expires_in": 600
}

The client renders otpauth_uri as a QR code itself; the API never serves the secret through a URL. Enrollment is pending until confirmed; calling this again replaces a pending secret. 409 mfa_already_enabled if already confirmed. A User who already has a passkey needs an MFA check in the last 10 minutes, here and at confirm (a step-up operation, 403 reauthentication_required).

POST /users/{id}/mfa/totp/confirm

// Request
{ "code": "492039" }
// Response — 200
{
  "mfa_enabled": true,
  "recovery_codes": ["7hq2-kx9m-a4vd", "p3nc-8wz1-qt6r", "..."]
}

Ten recovery codes, shown once, stored hashed, when TOTP is the User’s first MFA method. A User who already has a passkey keeps their codes, and recovery_codes is absent. Writes user.mfa_enabled and emails a notice. 401 invalid_mfa_code on a wrong code; 409 mfa_enrollment_expired if the pending secret is older than 10 minutes.

POST /users/{id}/mfa/totp/disable

// Request — a current TOTP code
{ "code": "492039" }
// Request — or a recovery code instead (exactly one of the two)
{ "recovery_code": "7hq2-kx9m-a4vd" }
// Response — 204

Same fields as mfa/verify (TOTP code or recovery code). Deletes the secret, and every recovery code unless the User still has a passkey. Writes user.mfa_disabled and emails a notice. Existing sessions are kept. Errors: 401 invalid_mfa_code (wrong or reused code), 409 mfa_not_enabled (no confirmed TOTP to disable), and 429 too_many_attempts after 5 wrong codes in 15 minutes.

DELETE /users/{id}/mfa/totp — support reset

Requires users.manage. For a User who has lost their device and their recovery codes; support verifies identity out of band first. Removes all of the User’s MFA: TOTP, every passkey, and the recovery codes. Revokes every session. Writes user.mfa_reset. 409 mfa_not_enabled when the User has neither TOTP nor a passkey. Destructive — see MCP Server → Tool annotations.

Passkeys

A passkey is a WebAuthn credential: a key pair held by the device (or synced by its platform, like iCloud Keychain or Google Password Manager) that signs a challenge for one site only, so a fake sign-in page can’t relay it. Here it’s a second factor, after the password, accepted wherever TOTP is. Passwordless sign-in is a later, separate step.

  • Relying party: RP ID substratalapps.com, so every first-party subdomain shares the same passkeys, and RP name “Substratal”. The allowed origins are the first-party CORS allow-list (CORS_ALLOWED_ORIGINS; in production https://hub.substratalapps.com) plus the origins of the hosted sign-in page (AUTHORIZE_URL; in production https://auth.substratalapps.com) and the dashboard (DASHBOARD_URL). Ceremonies can’t run in a cross-origin frame. A response from any other origin, or signed for another RP ID, is refused. Native Hub apps will also need Associated Domains and Digital Asset Links files on substratalapps.com; those come with the native apps.
  • User verification is required (a PIN, fingerprint, or face on the authenticator), at registration and at every use.
  • Up to 10 passkeys per User (409 passkey_limit_reached).
  • Challenges are valid 5 minutes and work once, even when two requests race with the same response. Asking for new options retires the previous challenge for the same ceremony and session (or sign-in), not the User’s ceremonies elsewhere.
  • Sign count: a passkey that reports a lower signature counter than the last one seen is refused (passkey_invalid) and logged as a security event, because it suggests a cloned authenticator. Synced passkeys (backup_eligible: true) legitimately don’t keep a counter, so they’re exempt.

POST /users/{id}/mfa/passkeys/registration-options

// Request
{}
// Response — 200
{
  "publicKey": {
    "rp": { "id": "substratalapps.com", "name": "Substratal" },
    "user": { "id": "dXNyXzAxSkFHM1o5WDhRUzNGNksyTTRONVA2UjdT", "name": "jordan@example.com", "displayName": "jordan@example.com" },
    "challenge": "Jx0h3dVv…",
    "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 }],
    "timeout": 300000,
    "excludeCredentials": [{ "type": "public-key", "id": "…" }],
    "authenticatorSelection": { "residentKey": "preferred", "userVerification": "required" },
    "attestation": "none"
  },
  "expires_in": 300
}

Self only. PublicKeyCredentialCreationOptions in their JSON form: pass publicKey to navigator.credentials.create() (via PublicKeyCredential.parseCreationOptionsFromJSON() where available). The User’s existing passkeys are excluded, so the same authenticator isn’t registered twice.

Adding a method needs a recent check, like the other step-up operations:

  • A User who already has an MFA method (TOTP or a passkey) needs an MFA check in the last 10 minutes (403 reauthentication_required).
  • A User adding their first method has nothing to step up with, so their session must have signed in with a password in the last 10 minutes instead (403 recent_sign_in_required, details.max_age_seconds: 600). Sign in again, then retry.

POST /users/{id}/mfa/passkeys

// Request — the PublicKeyCredential from navigator.credentials.create(), as JSON
{
  "credential": {
    "id": "…", "rawId": "…", "type": "public-key",
    "response": { "clientDataJSON": "…", "attestationObject": "…", "transports": ["internal", "hybrid"] },
    "clientExtensionResults": {}
  },
  "name": "MacBook Touch ID"
}
// Response — 201
{
  "passkey": {
    "id": "wac_01JAG8M2N3P4Q5R6S7T8U9V0W1",
    "name": "MacBook Touch ID",
    "aaguid": null,
    "transports": ["internal", "hybrid"],
    "backup_eligible": true,
    "backup_state": true,
    "created_at": "2026-11-05T09:41:03Z",
    "last_used_at": null
  },
  "mfa_enabled": true,
  "recovery_codes": ["7hq2-kx9m-a4vd", "p3nc-8wz1-qt6r", "..."]
}

Self only. Verifies the attestation against a challenge issued to this session by registration-options, and stores the credential. name is optional (default "Passkey", at most 64 characters). MFA is then on, and the session counts as freshly verified for step-up. recovery_codes is present only when this is the User’s first MFA method. Writes user.passkey_added (and user.mfa_enabled for a first method) and emails a notice. Errors: 400 passkey_invalid (wrong origin or RP ID, missing user verification, or an unknown, expired, used, or other session’s challenge), 409 passkey_already_registered, 409 passkey_limit_reached, 422 validation_failed (name).

GET /users/{id}/mfa/passkeys

Self or users.manage. { "data": [ …Passkey ] }, oldest first. Never includes the credential id or public key.

PATCH /users/{id}/mfa/passkeys/{passkeyId}

Self only. Renames: { "name": "Work YubiKey" } (1–64 characters). Takes If-Match; returns the Passkey with an ETag.

DELETE /users/{id}/mfa/passkeys/{passkeyId}

Self only, and a step-up operation: it needs an MFA check in the last 10 minutes. Writes user.passkey_removed. Removing the User’s last MFA method (no TOTP, no other passkey) is the same as turning TOTP off: the recovery codes are deleted, user.mfa_disabled is written, and the User gets the security.mfa_disabled alert and an email. 404 passkey_not_found for a passkey that isn’t the User’s.

Staff MFA

Staff are Users holding any platform Role that grants at least one permission (the same definition as the staff rate limit). The member Role grants nothing, so it doesn’t count. App Roles never count.

A staff User without MFA (neither TOTP nor a passkey) can always sign in, so nobody is locked out, the protected superadmin included. Once enforcement is on, though, that session can call only:

  • GET /users/{id} and GET /users/{id}/sessions. Only for itself, because the session holds no platform permission.
  • POST /users/{id}/mfa/totp and POST /users/{id}/mfa/totp/confirm, or POST /users/{id}/mfa/passkeys/registration-options and POST /users/{id}/mfa/passkeys, to enroll. A passkey is the recommended method for staff.
  • POST /users/{id}/password and POST /auth/logout.
  • GET /platform/settings, which every client reads at startup.

Everything else returns 403 mfa_enrollment_required. Assigning a staff Role to a User without MFA is allowed: the Role takes effect once they enroll TOTP or register a passkey, on their very next call. The User object and the assignment response say so with mfa_enrollment_required: true. Confirming TOTP enrollment or registering a passkey counts as a fresh MFA check for step-up.

Rollout: enforcement starts at STAFF_MFA_ENFORCE_AFTER, an RFC 3339 instant in the API’s configuration. In production that’s 2026-11-05T00:00:00Z, a grace period announced in the Changelog. Before it, staff without MFA keep working and aren’t asked to step up, but mfa_enrollment_required already says true so a console can prompt them. Left unset (local development, tests), enforcement is on from the start. The protected superadmin enrolls during the grace period like everyone else.

Step-up

A short list of sensitive operations needs a second factor proved within the last 10 minutes by the calling session:

  • creating a platform-scoped API Key (POST /api-keys with scope: "platform")
  • changing a key’s rate limit (PATCH /api-keys/{id} with rate_limit_per_minute)
  • assigning or removing a platform Role (POST/DELETE /users/{id}/roles/{roleId})
  • PATCH /platform/settings
  • deleting, suspending, or erasing a User (DELETE /users/{id}, POST /users/{id}/suspend, POST /users/bulk-suspend, PATCH /users/{id} to suspended, POST /users/{id}/erasure-requests)
  • adding a passkey when the User already has an MFA method (POST /users/{id}/mfa/passkeys/registration-options), and removing one (DELETE /users/{id}/mfa/passkeys/{passkeyId})
  • enrolling TOTP when the User already has a passkey (POST /users/{id}/mfa/totp and POST /users/{id}/mfa/totp/confirm)

openapi.yaml marks each with x-substratal-step-up (always, or conditional with x-substratal-step-up-when). Without a recent check they return 403 reauthentication_required with details.max_age_seconds: 600. The check is the session’s mfa_verified_at, set at MFA sign-in, at TOTP enrollment, at passkey registration, and by POST /auth/reauthenticate, and carried in the access token so a client can prompt first.

Step-up applies to User sessions whose User has MFA (TOTP or a passkey). Once staff MFA is enforced, that’s every staff member. A User without MFA has no second factor to prove, so a non-staff User deleting or erasing their own account isn’t asked to. API Keys have no session and aren’t affected; restrict_destructive is their guard.

POST /auth/reauthenticate

// Request — a current TOTP code
{ "code": "492039" }
// Request — or a recovery code instead
{ "recovery_code": "7hq2-kx9m-a4vd" }
// Request — or a passkey assertion, for options from POST /auth/reauthenticate/passkey-options
{ "passkey": { "id": "…", "rawId": "…", "type": "public-key", "response": { "clientDataJSON": "…", "authenticatorData": "…", "signature": "…" } } }
// Response — 200
{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNrXzIwMjYxMCJ9...",
  "expires_in": 900,
  "mfa_verified_at": "2026-11-05T09:41:03Z"
}

Needs a User token (400 user_token_required for an API Key). It sets the calling session’s mfa_verified_at to now and returns a new access token for the same session that carries it. The refresh token doesn’t change. Send exactly one of code, recovery_code, or passkey. They’re checked as in mfa/verify: a TOTP code can’t be reused within its window, a recovery code is single-use, and a passkey assertion must answer a challenge from POST /auth/reauthenticate/passkey-options issued to this session (same response shape as mfa/passkey-options; 409 passkey_not_registered without a passkey). Errors: 400 invalid_request (not exactly one), 401 invalid_mfa_code, 401 passkey_invalid, 409 mfa_not_enabled (neither TOTP nor a passkey to prove), and 429 too_many_attempts after 5 failures in 15 minutes. It stays open during read-only maintenance, and so do both passkey-options endpoints.

Sessions

GET /users/{id}/sessions

// Response — 200
{
  "data": [
    {
      "id": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8",
      "created_at": "2026-10-02T09:41:03Z",
      "last_seen_at": "2026-10-05T08:12:44Z",
      "expires_at": "2026-11-04T08:12:44Z",
      "ip_address": "203.0.113.24",
      "user_agent": "TimeTrack/2.4 (iOS 19.0)",
      "amr": ["pwd", "otp"],
      "current": true
    }
  ],
  "page": { "next_cursor": null, "has_more": false }
}

expires_at is when the session ends if nothing else happens: the earlier of idle_expires_at (30 days after the last refresh, here 2026-10-05) and absolute_expires_at (90 days after creation, 2026-12-31). Each refresh pushes the idle expiry forward, never past the absolute one.

DELETE /users/{id}/sessions/{sessionId}

204. Same effect as logout for that one session, including deactivating the push devices it registered. revoked_reason is logout when the User revokes their own session, and admin when a users.manage caller does.

POST /users/{id}/sessions/revoke-all

Signs a User out of every device in one call, for incident response on a compromised account that shouldn’t be suspended, or for a User who wants to sign out everywhere else.

// Request — body optional
{ "keep_current": true }
// Response — 200
{ "revoked": 3 }
  • Who: the User themselves, or a users.manage caller.
  • Effect: every live session is revoked with revoked_reason: revoked_all, exactly as if each were revoked one at a time. Their refresh tokens and app refresh tokens stop working, access tokens from them are refused with 401 session_revoked, and the push devices they registered are deactivated.
  • keep_current: keeps the session making the call. Self only; anyone else sending it gets 422 validation_failed with the field code self_only.
  • Result: revoked counts the sessions revoked. 0 is still a success.
  • Audit: writes user.sessions_revoked, with the count and keep_current in after.
  • Destructive: see MCP Server → Tool annotations.

POST /auth/sso/{provider}/callback

Reserved. Returns 501 not_implemented until the SSO broker integration ships (see Native auth and per-Organization SSO). When live, it creates or matches a method: sso UserIdentity against the Organization’s SSOConnection and returns an AuthSession — SSO is a different way to obtain a session, not a different kind of session.

Signing keys (JWKS)

GET https://api.substratalapps.com/.well-known/jwks.json
{
  "keys": [
    { "kty": "RSA", "kid": "sk_202610", "use": "sig", "alg": "RS256", "n": "0vx7agoebGcQSuu...", "e": "AQAB" }
  ]
}
  • RS256, 2048-bit keys, held in AWS KMS (the private key never leaves KMS — signing is a KMS Sign call).
  • Every token’s JWT header carries kid. Applications verify against the matching key and should cache the JWKS for up to 1 hour, re-fetching on an unknown kid.
  • Rotated every 90 days. A new key is published 7 days before it starts signing, and a retired key stays published for 7 days after its last use (longer than any token’s TTL), so a verifying Application never sees an unknown kid in normal operation.
  • Verification checklist for an Application: signature against JWKS, iss equals https://api.substratalapps.com, aud equals its own application_id, exp in the future (allow ≤60 s clock skew), entitlement_status is active.

GET /.well-known/openid-configuration returns issuer, jwks_uri, authorization_endpoint (https://auth.substratalapps.com/authorize, see The authorization endpoint), token_endpoint (/auth/oauth/token), response_types_supported (code), grant_types_supported (authorization_code, refresh_token), and code_challenge_methods_supported (S256). It is metadata for standard libraries, not a claim of full OpenID Connect conformance (there is no id_token or userinfo endpoint).

Errors specific to this resource

Code Status When
invalid_credentials 401 Login or password change with a wrong password/unknown email.
account_suspended 403 The User is suspended.
test_user_expired 403 A test-mode User whose expires_at has passed tried to sign in, refresh, or get an app token.
too_many_attempts 429 Account lockout after repeated failures.
email_taken 409 Signup with an email already in use.
signup_closed 403 Signup is closed for this mode by platform settings (signup_open: false). Invitations still work.
weak_password 422 Password fails the policy.
token_invalid 400 Verification, reset, or invitation token is expired, used, or unknown.
invalid_mfa_code 401 Wrong or reused TOTP/recovery code.
passkey_invalid 400 / 401 A passkey response didn’t verify (wrong origin or RP ID, bad signature, missing user verification, sign-count regression, or an unknown, expired, used, or mismatched challenge). 400 at registration, 401 at sign-in and re-authentication. See Passkeys.
mfa_token_invalid 401 MFA challenge expired or exhausted.
mfa_already_enabled / mfa_not_enabled / mfa_not_applicable / mfa_enrollment_expired 409 See MFA.
passkey_not_registered 409 Passkey options asked for by a User with no passkey.
passkey_already_registered 409 That authenticator’s credential is already registered.
passkey_limit_reached 409 The User already has 10 passkeys.
passkey_not_found 404 No such passkey for this User.
mfa_enrollment_required 403 A staff session without MFA (TOTP or a passkey) called something other than enrollment and self-service. See Staff MFA.
reauthentication_required 403 A step-up operation without an MFA check in the last 10 minutes. See Step-up.
recent_sign_in_required 403 Registering a first MFA method from a session signed in more than 10 minutes ago. Sign in again. See Passkeys.
refresh_token_reused 401 A rotated refresh token was presented again — session revoked.
session_revoked 401 The session was logged out, reset, or the User suspended/deleted.
user_token_required 400 An endpoint that acts as a User was called with an API Key.
entitlement_required 403 App-token issuance for a User without active access.
application_not_available 409 App-token issuance for an Application not approved.
tenant_suspended 403 App-token issuance for an Application whose Tenant is suspended.
application_not_found 404 Unknown or invisible application_id on app-tokens or oauth/authorization-codes.
redirect_uri_not_registered 422 Authorization code requested for an unregistered redirect_uri.
not_implemented 501 SSO callback before the broker integration exists.