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 /v1/auth/signup
  6. POST /v1/auth/login
  7. Test-mode Users
  8. POST /v1/auth/mfa/verify
  9. POST /v1/auth/token/refresh
  10. POST /v1/auth/logout
  11. Getting an app token
    1. The app token
    2. POST /v1/auth/app-tokens
    3. The authorization endpoint
    4. POST /v1/auth/oauth/authorization-codes
    5. POST /v1/auth/oauth/token
  12. Email verification
    1. POST /v1/auth/email/verify
    2. POST /v1/auth/email/verify/resend
  13. Password lifecycle
    1. POST /v1/auth/password/forgot
    2. POST /v1/auth/password/reset
    3. POST /v1/users/{id}/password
  14. Invitations
    1. POST /v1/auth/invitations/accept
  15. MFA (TOTP)
    1. POST /v1/users/{id}/mfa/totp — begin enrollment
    2. POST /v1/users/{id}/mfa/totp/confirm
    3. POST /v1/users/{id}/mfa/totp/disable
    4. DELETE /v1/users/{id}/mfa/totp — support reset
  16. Sessions
    1. GET /v1/users/{id}/sessions
    2. DELETE /v1/users/{id}/sessions/{sessionId}
  17. POST /v1/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) is optional and user-enabled for native accounts, through self-service enrollment. 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 /v1/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 /v1/auth/signup none Self-service account creation with email + password.
POST /v1/auth/login none Exchange email + password for a session (or an MFA challenge).
POST /v1/auth/mfa/verify none (mfa_token) Complete a login that returned an MFA challenge.
POST /v1/auth/token/refresh none (refresh_token) Rotate a platform refresh token for a new access token.
POST /v1/auth/logout Bearer (user) Revoke the current session, or every session.
POST /v1/auth/app-tokens Bearer (user) Mint a per-app JWT from a platform session (embedded login).
POST /v1/auth/oauth/authorization-codes Bearer (user) Mint a one-time authorization code for a hosted launch (PKCE).
POST /v1/auth/oauth/token none (code / refresh token) Exchange an authorization code or app refresh token for an app token.
POST /v1/auth/email/verify none (token) Confirm an email address from the link in a verification email.
POST /v1/auth/email/verify/resend none Re-send a verification email. Always 202.
POST /v1/auth/password/forgot none Send a password-reset email. Always 202.
POST /v1/auth/password/reset none (token) Set a new password from a reset token.
POST /v1/auth/invitations/accept none (token) Set a password on an invited account and activate it.
POST /v1/auth/sso/{provider}/callback none Complete an SSO login. 501 until the broker integration ships.
POST /v1/users/{id}/password Bearer (self) Change password, given the current one.
POST /v1/users/{id}/mfa/totp Bearer (self) Begin TOTP enrollment.
POST /v1/users/{id}/mfa/totp/confirm Bearer (self) Confirm enrollment with a first code; returns recovery codes.
POST /v1/users/{id}/mfa/totp/disable Bearer (self) Turn TOTP off, given a current code or recovery code.
DELETE /v1/users/{id}/mfa/totp Bearer (users.manage) Support reset for a user who lost their device.
GET /v1/users/{id}/sessions Bearer (self or users.manage) List active sessions.
DELETE /v1/users/{id}/sessions/{sessionId} Bearer (self or users.manage) Revoke one session.
GET /.well-known/jwks.json none Public signing keys. Outside /v1.
GET /.well-known/openid-configuration none Issuer metadata (issuer, JWKS URI, token endpoint). Outside /v1.

All unauthenticated POST /v1/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,
  "iat": 1730649600,
  "exp": 1730650500,
  "jti": "atk_01JAG8K5A1B2C3D4E5F6G7H8J9"
}

amr lists the authentication methods used (pwd, otp, recovery_code, sso). 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 /v1/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 /v1/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. See Test-mode Users.
  • 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 /v1/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", "recovery_code"],
  "expires_in": 300
}

Clients must branch on mfa_required before reading access_token. 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.
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 /v1/auth/signup test_mode creates the User in that mode.
POST /v1/auth/login test_mode picks which account the email refers to.
POST /v1/auth/password/forgot, POST /v1/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.

POST /v1/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" }
// Response — 200, an AuthSession

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. The mfa_token is single-use and valid 5 minutes; 5 wrong codes invalidate it.

Error Status When
invalid_mfa_code 401 Wrong or reused code.
mfa_token_invalid 401 Expired, already used, or exhausted. Start over at login.

POST /v1/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 /v1/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.

Getting an app token

Embedded login is allowed today because every Application is first-party, so no third party ever handles a Substratal password. It’s blocked for any Application not owned by Substratal once self-service registration ships, and the hosted sign-in page must exist before any third-party Application goes 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.
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.

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 /v1/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 /v1 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 /v1/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 /v1/auth/oauth/token

Called by the Application. A public-client endpoint: there’s no client secret, the PKCE verifier is the proof.

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

POST /v1/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 /v1/users/{id}/invitation — see Users), 422 weak_password.

MFA (TOTP)

Optional and self-service for password identities. 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).

POST /v1/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.

POST /v1/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. 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 /v1/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. Deletes the secret and every recovery code, 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 /v1/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. Revokes every session. Writes user.mfa_reset. Destructive — see MCP Server → Tool annotations.

Sessions

GET /v1/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 /v1/users/{id}/sessions/{sessionId}

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

POST /v1/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 (/v1/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.
too_many_attempts 429 Account lockout after repeated failures.
email_taken 409 Signup with an email already in use.
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.
mfa_token_invalid 401 MFA challenge expired or exhausted.
mfa_already_enabled / mfa_not_enabled / mfa_not_applicable / mfa_enrollment_expired 409 See MFA.
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.

Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.