Users and auth

Sign users up, sign them in with a token, and gate routes and records by role, permission, or ownership.

Docs menu

In your project

Sign-in settings (the function that sends your sign-in emails, link URLs, token lifetimes) are set in the [auth] table of substrio.toml and ship with substrio push (project-files.md). Your roles ([roles.NAME]) and social sign-in providers ([auth.providers.NAME], the secret taken from an environment variable) are declared there too and applied by substrio deploy; substrio push does not apply roles or providers. The function that sends the sign-in emails receives the event described in The mail function: email_function. To move an existing user base and sign-in flow, see bring-your-project.md.

User Management

Sign Up

curl -X POST $SUBSTRIO_URL/v1/_auth/register \
  -H "X-Substrio-Tenant: my-app" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "secret123"}'

Passwords are stored hashed, never in clear.

Login

curl -X POST $SUBSTRIO_URL/v1/_auth/login \
  -H "X-Substrio-Tenant: my-app" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "secret123"}'

Returns the session token and the user's id:

{"success": true, "user_id": 1, "token": "p4sH7M...", "keyring": "0xffffffffffffffff"}

Why a sign-up or sign-in failed

Each cause has a distinct code. Show the error message to the user as returned:

{"error": "That email already has an account. Sign in instead.", "code": "EMAIL_TAKEN", "status": 409,
 "retryable": false, "next_action": "sign_in"}
Code Value HTTP next_action error When
EMAIL_TAKEN 0xA01D 409 sign_in That email already has an account. Sign in instead. auth.register for an address that has an account; a provider's sign-in with an address it has not verified, when a person already holds it
CREDENTIALS_MISMATCH 0xA01E 401 correct_request That email and password do not match. auth.login with a wrong password, or an address nobody holds (the two read the same, so neither tells who has an account)
PASSWORD_LENGTH 0xA01F 400 correct_request Use a password of 8 to 128 characters. a password out of length, or none; field is password
EMAIL_INVALID 0xA020 400 correct_request Enter a valid email address. no address, or one past 255 bytes (and, for auth.email.code_request, one without an @); field is email
ACCOUNT_LOCKED 0xA022 423 choose_supported_operation This account is locked. Reset your password to sign in again. an admin's lock, or 10 failed sign-ins; auth.password.reset lifts it
ACCOUNT_SUSPENDED 0xA004 403 request_permission This account is suspended. the person is suspended or deleted
SERVER_BUSY 0x900C 503 retry The server is busy; retry shortly the HTTP password queue is full or stopping; retryable is true, and Retry-After is 1 second
USER_NOT_FOUND 0xA021 404 correct_request We could not find that person. a sign-in, reset or impersonation for an id no person holds

Password work during a burst

HTTP auth.register, auth.login, auth.password.change, and auth.password.reset run on a separate pool of password threads. A request in the queue holds no general worker. Argon2id strength, each stored hash's cost, and the timing-safe comparison stay the same.

The server settings password_hash_threads and password_hash_queue_capacity set the pool width and the number of waiting requests. The defaults are one quarter of the hardware threads, with at least one thread, and 64 waiting requests. Running requests are separate from that queue bound. Each Linux password thread starts at a nice value 5 greater than its inherited value.

A full or stopping queue returns 503 SERVER_BUSY before the auth operation runs. A refused password reset keeps its reset token. The response has retryable: true, next_action: "retry", and Retry-After: 1. Wait at least that interval before retrying. Existing request rate rules still return 429 RATE_LIMIT_EXCEEDED when their limits are reached.

For example, retry a sign-in after a busy response:

let response = await fetch(`${base}/v1/_auth/login`, options);
if (response.status === 503) {
  const refusal = await response.json();
  if (refusal.code === "SERVER_BUSY" && refusal.retryable) {
    const seconds = Number(response.headers.get("Retry-After") || 1);
    await new Promise(resolve => setTimeout(resolve, seconds * 1000));
    response = await fetch(`${base}/v1/_auth/login`, options);
  }
}

Authenticated admin password changes, synchronous in-process calls, and privileged archive passphrase derivation continue on their existing threads. Accepted work may finish after a connection closes. A retry is a new request and follows the same authentication rules.

The session token is an opaque value

The token is an opaque string with no dots, header, or claims to decode in the browser. It identifies a server-held session, which logout ends immediately. Do not parse the token. Store it and send it with later requests.

Read session details with this call once per session, and again when the page needs fresh values:

curl $SUBSTRIO_URL/v1/_auth/me \
  -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer <token>"
{"user_id": 1, "email": "[email protected]", "keyring": "0xffffffffffffffff",
 "email_verified": false, "mfa_enabled": false, "locked": false, "disabled": false,
 "admin": false, "source": "native",
 "created_at": 1791374435, "last_login": 1791374435,
 "name": "Ada Lovelace", "fields": {"phone": "+1 555 0100"}}

The response includes the user's id, email, verification state, and role flags (admin and keyring). A client reads role from this response. substrio op auth.me makes the same call. An app or account key is not a user session, so /v1/_auth/me returns 403 when called with one.

name and fields contain profile information and are always present. name is null when no name is set. fields contains the remaining members of the person's _users object that are not already in the response, or {} when there are none. Email, role flags, creation time, system fields (_id, _createdAt, _updatedAt, and _version), and any secret field are excluded. Reading them costs one additional read of the person's object per call.

Your own profile: auth.me.update

A signed-in person edits their profile with auth.me.update (PATCH /v1/_auth/me, substrio op auth.me.update). The body has two members, both optional:

Member Meaning
name The person's name, a string. Always the person's to set.
fields An object of _users fields. A member is written only when the app's _users schema marks that field "self": true (the schema guide, "Schemas"); one the app has not marked is the app's own to write (user.update).

The response is the updated auth.me. The edit is all-or-nothing: if any member is refused, nothing is written, including the name. A marked field must still pass its schema rule. Email and password use separate flows; including email or password in the body returns 400 "unknown member". A key is not a person, and returns 403 "A key is not a user".

# The app marks phone and picture as the person's own (once, as its owner).
curl -X PATCH $SUBSTRIO_URL/v1/_users/_schema \
  -H "X-Substrio-Tenant: my-app" -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"strict": false, "rules": [
        {"field": "phone", "type": "string", "max": 20, "self": true},
        {"field": "picture", "type": "string", "self": true}]}'

# The person edits their own profile with their session token.
curl -X PATCH $SUBSTRIO_URL/v1/_auth/me \
  -H "X-Substrio-Tenant: my-app" -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada Lovelace", "fields": {"phone": "+1 555 0100"}}'
# {"user_id":1,"email":"[email protected]", ... ,"name":"Ada Lovelace","fields":{"phone":"+1 555 0100"}}

# A field the app has not marked is refused, naming it, and nothing is written.
curl -X PATCH $SUBSTRIO_URL/v1/_auth/me ... -d '{"fields": {"plan": "pro"}}'
# 403 {"code":"SELF_FIELD_REFUSED","field":"plan", ...}
Code Value HTTP next_action When
SELF_FIELD_REFUSED 0xC08A 403 request_permission fields names a field the app's _users schema has not marked "self": true, or declares none; field names it
SCHEMA_REFUSED 0x8004 400 correct_request a marked field's value breaks its rule; violations lists them

Delete your sign-in profile: auth.me.delete

In the console, open Profile, choose Delete account, type DELETE, and confirm. Profile is available before email verification and account setup. Opening the confirmation or choosing Cancel sends no deletion request. After a successful deletion, the console signs you out and opens the sign-in page.

The confirmation calls auth.me.delete (DELETE /v1/_auth/me) with your session token. It permanently erases your user record, passkeys, MFA settings, and other authentication records. Your account memberships are removed first. It does not delete accounts or their apps. If you are an account's last owner, transfer ownership to another member or delete that account in its Settings before deleting your sign-in profile. If deletion is refused, the confirmation stays open with the reason. An expired session returns you to sign-in.

curl -X DELETE "$SUBSTRIO_URL/v1/_auth/me" \
  -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer <session-token>"
# {"success":true,"user_id":1,"hard":true,"dry_run":false}

Add ?dry_run=true to check whether deletion is allowed without removing anything. A successful check returns the same fields with dry_run: true. An API key cannot delete a sign-in profile. An impersonated session is also refused. After deletion, the previous session token no longer authenticates.

Code HTTP When
TOKEN_INVALID 401 Your session no longer authenticates. Sign in again.
INSUFFICIENT_PERMISSIONS 403 The session is impersonated, or account membership removal is not allowed.
ACCOUNT_LAST_OWNER 409 You are an account's last owner. Transfer ownership or delete the account first.

One login across apps

A person signs in once, at the identity app, and every app registered as its client (auth.client.*) signs them in without a password. The app sends them to the identity app's authorization address with an S256 PKCE challenge, then trades the code on the callback for a session in the app. On Substrio the identity app is AuthJet, at https://id.authjet.net.

The sign-in page names the app the person is going to. A featured app shows the title from its plans file and the /favicon.svg on its registered return address. Any other app shows its client id beside the AuthJet mark.

The browser goes to the identity app's address with these parameters. Discovery names this address as authorization_endpoint. /oidc/authorize takes the same parameters and always shows the sign-in page.

GET <identity app>/v1/_auth/authorize?response_type=code
  &client_id=<the client id auth.client.create gave>
  &redirect_uri=https://my-app.example/callback
  &scope=openid%20email
  &code_challenge=<base64url SHA-256 of the verifier>&code_challenge_method=S256
  &state=<your value, up to 1,024 bytes>&nonce=<optional, up to 512 printable bytes>
Parameter Required Refused with (sent back to redirect_uri)
response_type code unsupported_response_type: "response_type must be code"
client_id, redirect_uri a registered client and one of its redirect URIs invalid_request, answered on the page (never redirected to an unregistered URI)
scope must include openid; email adds email and email_verified, profile adds name and picture, to the id_token and userinfo invalid_scope: "scope must include openid"
code_challenge, code_challenge_method an S256 challenge invalid_request: "PKCE is required: an S256 code_challenge"
state optional, up to 1,024 bytes, returned as given invalid_request: "state is too long"
nonce optional, up to 512 printable bytes, copied into the id_token invalid_request: "nonce is too long or not printable"
login_hint optional, the email the sign-in page starts with. An address other than the signed-in person's shows the page dropped when over 320 bytes or not printable
prompt optional, login shows the sign-in page to a person who is already signed in, for "Use another account" ignored for any other value

scope has no default: a request without it is refused, as OpenID Connect requires. Discovery (/.well-known/openid-configuration) lists the supported scopes, openid, email and profile, and the claims they carry.

The profile scope provides the person's name and picture URL when those fields are set. A person can update picture with auth.me.update once the app marks the field "self": true. An unset name or picture is omitted from the claims rather than returned as null. Without the scope, neither claim is included. The id_token contains the values from the time the person allowed sign-in. /oidc/userinfo (Authorization: Bearer <access_token>) returns the current values, so a rename appears immediately.

The access token identifies the person, issue time, client app, and scopes. It is sealed with the server's secret and does not require an in-memory token record. An app can use the token until it expires or access is revoked. There is no limit on the number of live access tokens (oidc_max_access_tokens is gone).

  • Lifetime. oidc_access_token_seconds defaults to 3600 seconds (one hour). /oidc/token returns it as expires_in. The token is refused when it expires.
  • Sign-out. Signing the person out at the identity app (POST /v1/_auth/logout, or logout_all) ends every access token issued to them before it, at once, not at its end. A token is dated from the approval of its code, so a code approved before the sign-out and exchanged after it yields a token userinfo refuses. A token issued after signing in again works. The sign-out is stored, so it holds across a restart.
  • The app. A token identifies the client app it was issued to and carries only the scopes granted to that app. It cannot be converted into another app's token.
  • The person. /oidc/userinfo reads the person on every call: one who has been disabled, suspended or deleted gets 401 invalid_request ("A valid access token from /oidc/token is required") with a token still in date.

Coming back signed in

A person stays signed in at the identity app. When any app sends them to its authorization address, they come straight back with a code. There is no page, no form, and no prompt. It takes one request at the identity host.

  • The sign-in page keeps a stay. After any sign-in there it sends auth.refresh {"stay": true}, which sets the identity host's substrio_session cookie (HttpOnly, Secure, host-only, 30 days, renewed as it is used; see "Staying signed in" below). The cookie belongs to the identity host alone, and no app reads it.
  • What shows the page. The page appears when there is no stay, when an app asks with prompt=login, when login_hint names another address, when the person's account requires a second factor they have not set up, or the first time an outside app asks.
  • App consent. The first time a person signs in to an outside app, the page asks "Allow ... to know who you are?". They do not need to enter a password. Consent is stored in a platform record and survives a restart. Each app keeps a separate user record, found through a link to the identity user when the code is exchanged. Substrio products never ask, because they are one login.
  • POST /oidc/requests/{id} without approve returns the code when the person has allowed the app before. Otherwise it returns 400 invalid_request ("approve must be true or false").
  • Signing out of an app ends that app's session and leaves the login, so the next Sign In there comes straight back. The app can offer "Use another account" with prompt=login.
  • Signing out at the identity app ends the login: auth.logout from that browser clears the cookie and ends every app's session at its next refresh. auth.logout_all does the same for every browser the person has.

A return costs one base64url read, one SHA-256, and one keyed read of the stay, plus one keyed read of the consent for an outside app. A site needs no request to learn who is signed in elsewhere. A site shows Dashboard to a person with a session there, and Sign In to anyone else.

Connected apps: auth.connection.list, auth.connection.revoke

A person can see every outside app they allowed, and can disconnect any of them. Both calls take a person's session at the identity app. Substrio products are always connected, so they are not listed.

List auth.connection.list, GET /v1/_auth/connections, answers {"connections":[{"app":"partner","allowed_at":1793001800}]}
Disconnect auth.connection.revoke, DELETE /v1/_auth/connections/{app}, answers 204

A disconnect ends the person's sessions at that app, and the app asks for permission at their next sign-in. The app keeps its user record and the data it holds. An app the person never allowed answers 404. The console's Profile page shows both, on every address the console serves.

Signing out of one site: auth.logout_here

POST /v1/_auth/logout_here ends this session and this browser's stay on this host. The person stays signed in at the identity app and at every other app. The dashboards use it for Sign Out. It answers 204 and clears the cookie.

Substrio's dashboards

The console signs in the same way on every address. It is a client of the identity app itself, so auth.client.create with the identity app's slug registers a callback for each site, and only the identity app's owners can do that. auth.substrio at the identity app answers an identity session for the person, as they signed in, so the console reaches accounts and apps exactly as it does at substrio.com. A code for any other app never opens one. An app that uses custom provider keys gets an app session too, with no login at the identity app.

The console also keeps a sign-in form at ?direct, which sets up a required second factor. The sign-in page links there.

The callback exchanges the code:

curl -X POST $SUBSTRIO_URL/v1/_auth/substrio \
  -H "X-Substrio-Tenant: my-app" \
  -H "Content-Type: application/json" \
  -d '{"code": "...", "redirect_uri": "https://my-app.example/callback", "code_verifier": "..."}'

The response has the same fields as a password login: success, user_id, token, and keyring. The code is consumed in process and works once. The app's user is found first by a link to the identity user, then by the identity app's verified email. An existing password user is linked rather than duplicated. If no user is found, one is created.

A function sees the signed-in caller as request.auth: {user_id, email_verified, expires_at, accounts}. accounts lists the substrio accounts the person behind that user belongs to, as GET /v1/_accounts shows them (id, name, created_at, services, suspended, require_mfa, personal, and role: owner, admin or developer). It is the identity user's own list for the identity app, the linked identity user's in any other app, and empty for a user who never signed in through the identity app. Your app's rules and functions define what each role may do.

A session token from the identity app also works directly against another app your account owns. It is treated as the account key and administers that app, whether you hold the owner, admin, or developer role in the account. A token from another app, or from an account in which you hold no role, is refused.

A route can ask whether its caller has a permission by naming it in the gate. For example, "GET /hosts/mine auth?ADMIN" requires sign-in and returns 401 without a session. It also requests the permission result without denying the caller based on that result. request.auth.holds is true when the session has ADMIN directly, through a permission that implies it, or through the catalog's all name. Otherwise it is false. A route that does not request a permission has no holds member. A call made with an account key also has no holds member because a key is not a person and has no permissions. A name missing from the permission catalog fails deployment.

"POST /notes public?NOTES_EDIT" requests the result on a public route. A signed-in caller receives holds in the same way. With no credential, request.auth is null.

An account key scoped to the app can call auth and verified routes. A key has no permissions, so an auth:<name> route accepts it only when it belongs to the account that owns the app. Any other key receives 403 PERMISSION_DENIED. An owner gate such as "GET /contact-requests owner" accepts only a key of the owning account. It returns 401 CREDENTIAL_REQUIRED without a credential, and 403 PERMISSION_DENIED for another key or any person.

"GET /reports plan:exports" answers a caller whose plan at the app turns the feature exports on: the plan in force from every subscription they hold there, else the app's default plan (the subscriptions guide, "The gate word plan:feature"). A key is judged as its account, a person as themselves. A plan without it answers 403 PLAN_FEATURE_REQUIRED; a feature the app's plans file does not declare, 403 PLAN_FEATURE_UNKNOWN; no credential, 401.

A publishable account key (made by account.key.publish, safe in a page's source) calls only routes whose gate is publishable, as in "GET /tiles.json publishable", and only from one of its origins: any other route, origin or platform call answers 403 KEY_ORIGIN_REFUSED. For every other caller publishable is auth.

A browser may send its publishable key as ?access_token=<key> instead of an Authorization header, so a page's GETs need no CORS preflight (one round trip each, not two). It is read only on a publishable route with no key in a header, under the same origin and app checks; the app's request.url never holds it. A secret key in the URL is refused:

Code Value HTTP next_action When
SECRET_KEY_IN_URL 0xA023 400 correct_request a secret account key (scoped or not) sent as ?access_token= on a publishable route: send the publishable key there, or the secret key in Authorization from a server
KEY_ORIGIN_REFUSED 0xA016 403 request_permission a publishable key, in a header or the URL, at an Origin that is not one of its origins, or at a route that does not take one

MapLibre with a publishable key: every request to the API carries it in the query, none carries a header.

new maplibregl.Map({
  container: 'map',
  style: 'https://api.mapjet.net/styles/atlas',
  transformRequest: (url) =>
    url.startsWith('https://api.mapjet.net/')
      ? { url: url + (url.includes('?') ? '&' : '?') + 'access_token=' + PUBLISHABLE_KEY }
      : undefined,
});

Some routes cannot name their organization in the path: the body names a document, or the handler reads a row, and that row names the organization. A gate that reads the path cannot protect those. Check the permission in the handler, on the value it read:

const NOTES_EDIT = db.permission('NOTES_EDIT');     // once, when the module loads

module.exports = async function (request) {
  const note = db.collection('notes').get(request.params.id);
  db.requirePermission(NOTES_EDIT, request.auth, note.team);
  // only a caller who may edit this team's notes gets here
};

db.permission(name) resolves a declared permission once. A name absent from the catalog throws a TypeError when the module loads. db.requirePermission(permission, request.auth, organization) takes the organization's document id as text, the value referenced by a membership. It returns when the caller's membership grants the permission or when the route asked auth?<that name> (or the catalog's all name) and holds is true. Otherwise, the call returns the same refusal as a route gate: 403 PERMISSION_DENIED, or 401 CREDENTIAL_REQUIRED without a signed-in person. It is a response, not a failed function. No function error is recorded, and code after the call does not run. Do not catch it. A row without an organization is accessible only to someone who holds the permission everywhere.

db.requirePersonOrPermission(person, permission, request.auth, organization) also permits the person named by the row (request.auth.user_id === person). Use it when a handler may act for that person or for someone with the permission. db.requireVerified(request.auth) applies the verified route gate inside a handler, useful when verification depends on a row it reads. It permits a signed-in person with request.auth.email_verified, or an app key. It returns 401 CREDENTIAL_REQUIRED without a signed-in person and 403 EMAIL_VERIFICATION_REQUIRED for anyone else.

Declare the route auth?<the name the handler requires> so that a person who holds the permission everywhere passes without a membership. On a route that asks nothing, only a membership passes. A member pays one membership lookup per call; a holder everywhere pays none; a route that does not call it pays nothing.

A route gate names the organization the permission must be held in: auth:[email protected] takes it from the path parameter org, auth:EDIT@orgs.?org from the query parameter org (absent or empty is refused), and auth:EDIT@orgs.??org from the query parameter org when it is given: absent or empty, any signed in caller passes and the handler answers for what the caller reaches itself; given, a membership in it is needed as above. auth:EDIT@* passes a caller who holds the permission in at least one organization. Each is decided before the handler runs: one membership lookup a call, none when the optional parameter is absent.

A handler that found a person by a lookup (an address, a field the app declared on _users) and needs that person's number, the one request.auth.user_id holds, gets it with db.userIdOf:

const person = db.collection('_users').eq('email', db.arg(0)).first(address);
const userId = db.userIdOf(person);     // a number; no read

db.userIdOf(person) takes a record of _users and reads nothing: a person is keyed by the user id, and a record's own _id is that key as text. Anything that is not a person is a TypeError.

A person created by the identity app receives a personal account immediately, whether they sign up by password or through social or identity-app sign-in. They are its owner, and the account has personal: true. Its name comes from their display name or the local part of their email address. The account is created once. It is removed with the person on hard deletion if it has no other members and owns no apps.

Your account opens to 2 GB when you sign in with a provider

A new account has 500 MB of space across its projects. The first time its owner signs in with Google, GitHub, or another provider, it receives the space allowed by its plan (2 GB on Free). An email code, password, passkey, or agent console link does not add that space.

  • Which accounts start at 500 MB: every account made with an owner. That includes the personal account of a person who signs up by email code or password, an account an agent makes with agent.signup.finish, and an account a person makes with POST /v1/_accounts. A person who signs up with a provider gets their plan's space at once.
  • What opens it: a sign in at the identity app with a third-party identity provider: Google, GitHub, Apple, Microsoft, Facebook, X, Discord, or an OIDC provider (/v1/_auth/google, /v1/_auth/github, /v1/_auth/oauth). Every account the person owns opens at that sign in, once. An account where they are only a member stays as it is; its owner opens it.
  • What never opens it: an email code (auth.email.code_verify), a password (auth.login, a reset), a passkey, 2FA, an agent's console link (auth.link.redeem), and the identity app's own sign in at another app (/v1/_auth/substrio).

Space here is enforced the same way as everywhere else (SPACE_FULL, see plans and limits).

Signing out at the identity app (/v1/_auth/logout or logout_all) signs the person out of every app they reached through it, at each app's next request: a linked user's token issued before the sign-out is refused, by every request and by /v1/_auth/refresh. No app is called; the check is cheap.

Continue with Google, Apple, Microsoft, Facebook or GitHub: auth.signin_providers

Sign-in pages show a "Continue with ..." button for each available provider. The console and identity app's /oidc/signin page read the list from auth.signin_providers (GET /v1/_auth/signin-providers). The operation requires no session and returns no secret. Each entry contains the provider, button label, icon, and authorization URL. The URL includes the public client id, return address, scopes, and response_type=code. The page adds state. The icon is single-color SVG path data in a 24 by 24 box. A page can draw it in its text color with <svg viewBox="0 0 24 24"><path d="..."/></svg>.

curl https://<identity host>/v1/_auth/signin-providers
# {"providers":[{"provider":"google","authorize":"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=...&redirect_uri=https%3A%2F%2Fauthjet.net%2Fauth%2Fgoogle%2F&scope=openid%20email%20profile&prompt=select_account"}]}
  • Which providers are listed: every provider in the catalog that has a registration, in the catalog's order (see auth.provider.catalog below). Microsoft signs in work, school and personal accounts (the common endpoint). The app's own registration counts, and so does the identity app's shared one. A registration is listed once it is enabled and names its redirect_uri. An app that signs people in at the identity app lists no registration.
  • The list for one app: the hosted page adds ?client=<client id> at the identity app. That app's own registrations then replace the shared ones, each row naming the app in client, and the providers it turned off are left out. A client that names no registered client changes nothing.
  • The return address: the shared registrations return to https://authjet.net/auth/<provider>/. That page hands the code and the state to the page whose button started the sign-in, in the URL's fragment. A state that starts with c. goes to the console sign-in at substrio.com. Any other goes to /oidc/signin at id.authjet.net.
  • Apple: Apple sends the name and email only by a form POST to the return address, so its URL asks for code id_token with response_mode=form_post. The route at https://authjet.net/auth/apple/ takes that POST and forwards the signed id_token, the state and, on the first sign-in only, the person's name. The page sends {"provider":"apple","token":"...","name":"..."} to POST /v1/_auth/oauth, which checks the token against Apple's keys and the registration's Services ID.
  • Microsoft and an existing account: Microsoft's mail is whatever a work tenant's admin typed, so it never joins an existing account by itself. The ID token from the same exchange vouches for the address when the account is personal, or when the work tenant proved it owns the domain (xms_edov). Only then does a Microsoft sign-in join the account that already has that address. A work tenant adds the optional claims email and xms_edov to the app registration's ID token for this.
  • The exchange: the page checks the state against the one its button kept in the tab. Then it trades the code for a session with POST /v1/_auth/oauth ({"provider":"google","code":"..."}). A provider sign-in also opens the account's 2 GB, as described above.

This operation has no separate refusal. A provider without a return address is omitted, and an app with no provider returns {"providers":[]}.

Every provider and its fields: auth.provider.catalog

The platform supports 13 providers. Google, Apple, Microsoft, Facebook, GitHub, and Discord each have a dedicated exchange. LinkedIn, GitLab, Slack, Twitch, Yahoo, and Salesforce use OpenID Connect, with endpoints read from each provider's discovery document. The final entry, oidc, supports single sign-on with an OpenID Connect server such as Okta, Auth0, Keycloak, Cognito, or your server.

GET /v1/_auth/provider-catalog lists them all. It needs no session. Each row names the provider, its button label and the fields its registration takes. It also gives the default scopes, whether the provider answers by a form POST, where the client is registered and how long its secrets live.

curl https://<host>/v1/_auth/provider-catalog
# {"providers":[{"provider":"microsoft","label":"Microsoft","protocol":"native",
#   "fields":["client_id","client_secret","redirect_uri"],"scopes":"openid email profile User.Read",
#   "return":"get","console_url":"https://entra.microsoft.com/...","secret_life_days":730,
#   "note":"Work, school and personal accounts. Copy the secret's Value, not its Secret ID."}, ...]}
Field Who needs it
client_id Every provider. Apple calls it the Services ID.
client_secret Every provider. Apple takes the Sign in with Apple key (the .p8 file's text).
team_id, key_id Apple only. They sit beside the key.
provider_url oidc only. It is the server's issuer, where its discovery document lives.
authorize_url oidc only. It is the server's authorize endpoint.
redirect_uri Every provider. The provider sends people back to this address, and a registration without one shows no button.

A shared registration must name a provider in the catalog. Anything else is refused with 400.

Your own registration over AuthJet's shared login

Every app starts with AuthJet's shared logins, so its hosted sign-in page shows the provider buttons without setup. To show your app's name on a provider's consent screen, register a client with the provider and save it with auth.provider.update. That registration takes precedence for the provider on your app's hosted sign-in page. Register AuthJet's return address with the provider, and your site needs no extra page.

curl -X POST https://<app host>/v1/_auth/providers \
  -H "Authorization: Bearer $KEY" \
  -d '{"provider":"github","client_id":"...","client_secret":"...","redirect_uri":"https://authjet.net/auth/github/"}'

The person signs in with your registration and returns through your usual callback page. The hosted page sends its request to auth.oauth with the code, and the answer is redirect_to, which carries your app's code. There is no token in that answer, because you hold the secret of that registration. The sign-in reaches your app alone and opens no session at the identity app, so another app asks the person to sign in again. Only a provider whose endpoints come from the catalog runs this way. A registration that names provider_url or authorize_url leaves the shared login on the hosted page, and it shows on a sign-in page you build from your app's own auth.signin_providers. That page's redirect_uri is on your site, and it sends the code to auth.oauth at your app.

Turn a provider off for one app by sending enabled: false. There is no need for a client of your own. The provider leaves that app's hosted page, and auth.oauth refuses it with PROVIDER_DISABLED:

curl -X POST https://<app host>/v1/_auth/providers \
  -H "Authorization: Bearer $KEY" \
  -d '{"provider":"github","enabled":false}'

Removing the record with auth.provider.delete restores AuthJet's shared login for that provider. Users retain their accounts. For single sign-on, specify the server as well:

{"provider":"oidc","client_id":"...","client_secret":"...",
 "provider_url":"https://sso.example.com","authorize_url":"https://sso.example.com/oauth2/authorize",
 "redirect_uri":"https://myapp.example/auth/sso"}

Each app's AuthJet page in the console, /t/<app>/jets/authjet/setup, turns sign-in on and holds the client ID, the issuer, and the code to add to the site. The same page lists every provider in the catalog, and so does Users and access under Sign-in. Each row shows Your login, AuthJet shared, Off, or Not available, and offers Use your own and Turn off.

When a client secret expires: secret_expires_at

Some providers' secrets expire. Microsoft's last two years at most. A registration can record the date as secret_expires_at, in Unix seconds, and the listings show it beside the client id. The secret itself is never shown.

  • A new secret clears the date. The old date belonged to the old secret. Send the new date in the same call to keep one recorded.
  • An update without a secret keeps the date. A change to the scopes or the return address leaves it as it was.
  • 0 removes the date.

The console marks a secret that expires within 30 days, and one that has expired. It also flags a provider whose secrets expire when no date is recorded.

The console supports passwordless sign-in by email. Enter an email address and choose "Send login link". The identity app's auth.email.code_request sends a link and a six-digit code together. At "Check your email", use either one:

  • The link. It opens the console at #verify=<token> (the identity app's verify_url is https://substrio.com/app/#verify={token}). The console reads the fragment once, removes it from the address (history.replaceState) before anything else, and spends it with one auth.email.code_verify {token}, sending no other credential. The fragment never reaches a server.
  • The code. It is typed on the "Check your email" step, and the last digit signs the person in with one auth.email.code_verify {email, code}. There is no button to press.

For a new person (created: true), the user is created and signed in immediately without a 2FA prompt at sign-up. The Customer page offers 2FA later. A person with 2FA enabled receives a challenge under mfa instead of a token. The console asks for the code or sets up 2FA when the account requires it.

"Send again" mails the pair again. While a pair lives (not spent, not past its 30 minutes, fewer than 8 wrong tries), every request mails the same code and link, and the 30 minutes start over, so any of the mails works, one that arrives late too; the wrong tries stay counted. A spent, expired or locked pair gives way to a new one. Inside the app's request interval the request is refused 429, which the console reads as "A code was just sent. Check your inbox or try again in a minute." "Use a different email" goes back to the first step.

The console stays signed in on this browser: the sign-in sets the stay cookie, and a load trades it for a fresh token. A renewal spends the token it replaces, so the console runs one renewal at a time and every call waits for it. A call refused 401 for a token the tab has since replaced, or can replace through the stay cookie (a tab that slept past its renewal), is sent once more with the new token. A renewal the server does not answer leaves the session signed in and is asked again 30 seconds later. Only the server's refusal of the session itself signs the tab out.

The password stays one link away, "Use a password instead": the email and password step (auth.login), with "Create an account" (auth.register) beside it. Its refusals read as their codes say them (above).

The server's own sign-in pages sign in the same way and look like the console: the OAuth consent page an MCP client opens (/oauth/consent, on mcp.substrio.com for a platform account and on every app's host for that app's users), the approval page an agent's irreversible call links to, and the identity app's /oidc/signin and /oidc/invite. A mailed code is the default step (auth.email.code_request, then auth.email.code_verify on the sixth digit), "Use a password instead" is one link away (auth.login), and a two-step code follows either one (auth.mfa, or a recovery code). An app with no email_function refuses the code (PROVIDER_NOT_CONFIGURED), and the page opens its password step with that message. The pages type the code rather than follow the mail's link, so they work whatever the app's verify_url names. See MCP: the built-in sign-in.

The console costs 1 request to send and 1 to verify. The code entry is one component the Customer page shares for proving an email. lib/link.ts is the one owner of a sign-in in the fragment (#link= below, #verify= here): a mail link that proves nothing leaves its reason (already used, expired, too many tries) for the sign-in page to show, as a refused #link= does.

The bot check on code mails: Turnstile

The platform identity app requires a Cloudflare Turnstile check before sending a code. This covers console sign-in, /keys, the OAuth sign-in page, the agent sign-in page /start (agent-sessions.md), and direct auth.email.code_request calls to the identity app. Code emails from other apps are not checked.

A page calls auth.turnstile (GET /v1/_auth/turnstile) for the site key and gate state. It loads https://challenges.cloudflare.com/turnstile/v0/api.js with async and defer, renders the widget with that site key and the page's action, and sends the token as turnstile_token with the email request. A token can be used once, so the page resets the widget after each request that carried one, regardless of the response.

The platform checks each token with Cloudflare's siteverify, with a 10 second timeout. The check runs on the server's work pool and never holds an event loop. It sends the secret, the token, and the caller's address as the server sees it (no proxy header is read). A token passes when Cloudflare answers success, its hostname is in turnstile_hostnames, and its action is one the surface takes:

Surface Action
/start (agent.login.challenge) agent_login
a direct auth.email.code_request code_request
console sign-in console_signin
the OAuth sign-in page oauth_signin
/keys keys

agent.login.challenge takes agent_login alone. auth.email.code_request takes the other four. A token made on another page is refused. With one of Cloudflare's published test secrets, the action test its test widgets answer is taken too.

The gate fails closed. While turnstile_required is true and no secret is set, code emails are refused with TURNSTILE_REQUIRED, and auth.turnstile returns gate closed. The server also prints a warning at startup. Setting turnstile_required to false turns the gate off (gate off).

After the check, the platform host mails at most auth_code_mails_per_hour codes in an hour to people who are not signed in, counting every caller. Past it, the request answers 429 RATE_LIMIT_EXCEEDED with Retry-After.

Settings

Setting Kind Default What it does
turnstile_site_key text 0x4AAAAAAFrPYXiNsKYyDrM7 The public site key the pages render the widget with.
turnstile_secret secret none The widget's secret. A read answers set and never the value.
turnstile_hostnames text substrio.com,mcp.substrio.com The hostnames a token may come from, comma separated, at most 8. A list with a bad name is refused whole.
turnstile_required bool true False opens the gate. True with no secret refuses every code mail.
auth_code_mails_per_hour integer 1000 The platform host's hourly cap on code mails to people who are not signed in.
agent_login_seconds integer 900 How long an agent's sign-in request lives.
agent_login_ip_per_hour integer 10 Sign-in requests one address may begin in an hour.
agent_login_net_per_hour integer 30 Sign-in requests one network (an IPv4 /24 or an IPv6 /48) may begin in an hour.
agent_login_access_seconds integer 3600 How long a signed-in CLI's access secret works before the CLI refreshes it.
agent_login_refresh_days integer 30 How long a signed-in CLI's session lasts, counted again from each refresh.
agent_refresh_retry_seconds integer 60 How long a refresh token that a refresh replaced still answers that same refresh, for a client whose answer was lost. After that it is refused. It applies to CLI sessions and OAuth connections alike.
agent_login_url text https://substrio.com/start?intent= The page an agent's sign-in request links to, with the request's id appended.
agent_login_dashboard_url text https://substrio.com/app/ Where the CLI sends the browser after the sign-in.

Each is a platform setting (PUT /v1/_platform/settings/{key} with {"value": ...}).

Refusals

Code Status When What the page says
TURNSTILE_REQUIRED 403 the request carried no token, or the gate is closed Finish the check below, and the code is sent.
TURNSTILE_FAILED 403 Cloudflare refused the token, it was spent, or its hostname or action is not the surface's The check did not pass. Try it once more.
TURNSTILE_UNREACHABLE 503 Cloudflare could not be reached, or it refused the platform secret (logged once as TURNSTILE_SECRET_REFUSED, with no secret) The check could not be confirmed just now. Try again in a moment.

No mail is sent on any of them.

# The page asks for the site key.
curl -s https://mcp.substrio.com/v1/_auth/turnstile
# {"site_key":"0x4AAAAAAFrPYXiNsKYyDrM7","gate":"on"}

# The code request with the widget's token.
curl -s https://mcp.substrio.com/v1/_auth/email/code_request -H 'X-Substrio-Tenant: substrio' \
  -H 'content-type: application/json' -d '{"email":"[email protected]","turnstile_token":"0.AbC..."}'
# 202 {"accepted":true,"expires_at":1793001800}

An agent that signed a person up using auth.email.code_verify or agent.signup.finish can open their dashboard in a browser without a password. Create a one-time link and open its url:

Mint account.console.link, POST /v1/_accounts/console/link, MCP substrio_account_console_link, no body
Answer {url, expires_at}: url is the console with the link in its fragment, https://substrio.com/app/#link=<token>
Redeem auth.link.redeem, POST /v1/_auth/link/redeem with {token}, at the identity app: the console calls it, never the agent

The caller must represent a person: an agent session can create a link for the member it represents in that account, and a person signed in to the identity app can create one for themselves. App, account, and agent keys, as well as agent tokens, are refused with CONSOLE_LINK_NEEDS_PERSON.

The link can be used once and expires after 5 minutes. A new link for the same person replaces the previous link. Only the token's SHA-256 is stored, under the person's id. Nothing is added to other requests.

The token is in the URL fragment (#link=), which the browser does not send to the server. It is therefore absent from the request line, access log, and Referer. The console reads the fragment once, removes it from the address with history.replaceState, and redeems it. The response contains the same sign-in fields as auth.email.code_verify: {user_id, account_id, token, keyring}. If the person has 2FA enabled or their account requires it, the response contains a challenge under mfa instead of a token. Exchange the challenge with auth.mfa as after a password sign-in.

A session created by a link acts as the person, with the approval requirements of an agent key. Its token carries a signed mark that cannot be removed and is retained on refresh. With this session:

  • every call it may make runs as the person's, except an irreversible one (account.key.create, an app's deletion), which waits for an approval, exactly as an agent key's does (APPROVAL_REQUIRED, then the same call repeated once it is approved);
  • it cannot list, approve or deny an approval (agent.approval.list, approve, deny) and cannot make an automation key: those answer CONSOLE_LINK_STEP_UP.

The approval appears in the account the call acts in, either the app's account or the account named by the route. It identifies the person and session token. The person, an admin, or an owner of that account can decide it. A refresh creates a new token, so a call pending before refresh must request approval again.

To take full authority the person steps up: they sign in again by the email code (auth.email.code_verify) or with 2FA (auth.mfa). That is a fresh session with no mark. The console does it for them: a link's session that meets CONSOLE_LINK_STEP_UP or an approval's wait shows the email code step, and the page comes back once the code matches. This keeps the link from turning an agent into the person: the agent holds the URL, so it could redeem the link itself, but the session it gets cannot approve its own irreversible calls.

Code Value HTTP When
CONSOLE_LINK_NEEDS_PERSON 0xC0B8 403 The minting caller is no person (a key or an agent token)
CONSOLE_LINK_UNKNOWN 0xC0B9 404 No live link has this token: mistyped, made up, or replaced by a newer link
CONSOLE_LINK_USED 0xC0BA 410 The link already signed its person in once
CONSOLE_LINK_EXPIRED 0xC0BB 410 The link is past its life
CONSOLE_LINK_STEP_UP 0xC0BC 403 A link's session asked to decide an approval or make an automation key: sign in again by the email code or 2FA

Each refusal's next_action is reauthenticate: the person signs in another way, or the agent mints a new link. A refused redeem changes nothing. The mark costs nothing on any request: it rides the token the redeem writes anyway, the session reads it from the byte it already loads, and the approval branch's one test of the session's access byte covers it with the agent key's bit. Only the approval operations add a test.

# The agent, with the session its sign-up gave it: mint.
curl -X POST https://mcp.substrio.com/v1/_accounts/console/link \
  -H "Authorization: Bearer $SESSION"
# {"url":"https://substrio.com/app/#link=substrio_cl_000000000000002a...","expires_at":1793145600}

# The console, in the person's browser, with the fragment's token (no credential).
curl -X POST https://mcp.substrio.com/v1/_auth/link/redeem \
  -H "X-Substrio-Tenant: substrio" -H "Content-Type: application/json" \
  -d '{"token": "substrio_cl_000000000000002a..."}'
# {"user_id":42,"account_id":7,"token":"...","keyring":"0x..."}

Two flags set the console address and the link's life: --console-link-url (default https://substrio.com/app/#link={token}; its {token} must follow a #, or the server does not start) and --console-link-seconds (default 300, at least 60).

A credential in the URL's path or query, and a signed stateless token that needs no store, were refused: the first reaches servers and logs, and the second could be replayed until it expired.

Multi-factor sign-in

A user turns on TOTP (RFC 6238: SHA-1, 30 s steps, six digits, one step of drift either way) with an authenticator app:

# Start: the secret and the otpauth:// URI to scan. MFA is still off.
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa/totp -H "X-Substrio-Tenant: my-app" -H "Authorization: Bearer $TOKEN"
# Confirm with a code: MFA is on, and the answer holds a new session and ten
# recovery codes, shown this once.
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa/totp/confirm -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $TOKEN" -d '{"code": "123456"}'

Afterward, a password or social sign-in returns 401 with {"error": "mfa_required", "challenge": "substrio_mfa_...", "expires_at": ..., "attempts_left": ...} and no session. Exchange the challenge and an authenticator or recovery code for the session:

curl -X POST $SUBSTRIO_URL/v1/_auth/mfa -H "X-Substrio-Tenant: my-app" \
  -d '{"challenge": "substrio_mfa_...", "code": "123456"}'
  • A challenge expires after 5 minutes, works once, and is invalidated after 5 wrong codes.
  • A code's time step counts once: the same code, or an earlier one, is refused (reused_code).
  • A recovery code works once. POST /v1/_auth/mfa/recovery_codes (with a code) replaces them all; POST /v1/_auth/mfa/disable (with a code or a recovery code) turns MFA off. Both take mfa_attempts wrong codes in a row per user (attempts_left in each 401); then both answer 429 mfa_locked, whatever the code, for mfa_lockout_seconds (default 900).
  • Turning MFA on or off, or replacing the recovery codes, ends every other session of the user at its next request, and at the identity app every app session signed in through it. The session that made the change keeps working (the user's cutoff record keeps its signature).
  • The secret is sealed to the server's own seal key (seal_key_file, AES-256-GCM) in a platform record; only SHA-256 digests of the recovery codes are stored.

Remembered devices: the authenticator code, no email

A person with 2FA enabled signs in with an email code and then an authenticator code. After both steps, they can remember the device. The next sign-in on that device requires only the authenticator code and sends no email. The device and the code are the two factors.

  1. Remember. auth.mfa (POST /v1/_auth/mfa) with "remember": true, finishing a challenge that auth.email.code_verify answered (the email code, or its link), sets a cookie on its answer:

    Set-Cookie: substrio_device=<43 characters>; HttpOnly; Secure; SameSite=Strict; Path=/v1/_auth; Max-Age=2592000

    The cookie is 32 random bytes in base64url, handed out this once. The server keeps only its SHA-256, under the person, in platform records, written before the answer is sent. A challenge from a password sign-in, a provider or a console link remembers nothing; remember is false when absent. The console sends remember: true unless the person unticks "Remember this device for 30 days".

  2. Use. auth.email.code_request (POST /v1/_auth/email/code_request) from that browser carries the cookie. When it is a live grant of the person the address names, and their 2FA is on, no email is sent and the answer is the challenge itself:

    {"accepted": true, "expires_at": 1793000300, "mfa_required": true, "challenge": "substrio_mfa_..."}

    auth.mfa finishes it with the authenticator code or a recovery code, as any challenge: the same 5 minutes, the same wrong-code limit. The session is a second-factor sign-in, as after the email code.

  3. Other requests. With no cookie, another person's cookie, a forged or expired cookie, an unknown address, or 2FA disabled, the response is {"accepted": true, "expires_at": ...} and the normal email flow applies. The response is identical for every address and does not reveal whether the address exists or has 2FA.

A grant ends:

  • after 30 days (mfa_device_seconds in substrio.conf, also the cookie's Max-Age);
  • when a challenge it gave runs out of wrong codes: that device signs in by the email again;
  • for every device of the person at once, with everything that ends every session: auth.logout_all, auth.mfa.disable, auth.mfa.recovery.regenerate, turning 2FA on, a password change or reset, a lock, a suspension, or the person's deletion;
  • with auth.devices.forget, which ends every remembered device and no session (a browser kept signed in stays so).

auth.logout ends a session and keeps the device: the next sign-in there is the email address, then the authenticator code, with no mail.

Operation Route Body Answer
auth.devices GET /v1/_auth/devices none {"count": 2}: the person's live remembered devices
auth.devices.forget POST /v1/_auth/devices/forget none 204: every remembered device ends; the person stays signed in

Both require the person's session. A key or agent token is not a person. The 2FA card in the console shows "Remembered devices: N" with "Forget all devices". On the sign-in page, "Use email instead" asks for the code again without the cookie (credentials: 'omit'), so the email is sent.

Code Value HTTP When
TOKEN_INVALID 0xA001 401 auth.devices or auth.devices.forget with no session of a person (a key, an agent token, or none)
STORAGE_IO 0x1000 500 the grant could not be stored or ended: auth.mfa answers no session (the challenge is spent: sign in again), and auth.devices.forget ended nothing

A wrong code on a remembered device's challenge is auth.mfa's own 401 {"error": "invalid_code", "attempts_left": n}; at 0 the challenge and the grant are gone.

# Sign in by the email code, then the authenticator code, remembering this device.
curl -c jar -X POST $SUBSTRIO_URL/v1/_auth/mfa -H "X-Substrio-Tenant: my-app" \
  -d '{"challenge": "substrio_mfa_...", "code": "123456", "remember": true}'
# Next time: the cookie stands in for the email.
curl -b jar -X POST $SUBSTRIO_URL/v1/_auth/email/code_request -H "X-Substrio-Tenant: my-app" \
  -d '{"email": "[email protected]"}'
# {"accepted":true,"expires_at":1793000300,"mfa_required":true,"challenge":"substrio_mfa_..."}
curl -X POST $SUBSTRIO_URL/v1/_auth/mfa -H "X-Substrio-Tenant: my-app" \
  -d '{"challenge": "substrio_mfa_...", "code": "654321"}'

A session token lasts for an hour. A second cookie keeps the person signed in on the browser for 30 days, so sign-in and 2FA are requested again only after they sign out.

  1. Keep. auth.refresh (POST /v1/_auth/refresh) with the session and {"stay": true} renews the token, as any refresh, and sets:

    Set-Cookie: substrio_session=<43 characters>; HttpOnly; Secure; SameSite=Lax; Path=/v1/_auth; Max-Age=2592000

    The cookie is the person's id and 24 random bytes in base64url. The server keeps only its SHA-256, under the person, in platform records, written before the answer is sent, with what the sign-in proved (an admin's second-factor claim stays; a console link's session stays a link's). A token another person stands in for (an impersonation) is renewed and kept nowhere.

  2. Trade. auth.refresh with no Authorization header and the cookie answers a fresh session for that person, signed in as the stay was: {"success": true, "token": "..."}. No cookie, or a forged, ended or expired one, answers 401 TOKEN_INVALID ("Bearer token required"), the bytes every refresh with no credential gets.

  3. Slide. A trade made once a thirtieth of the cookie's life has passed (a day, at 30 days) renews it: the answer sets the same cookie again with a full Max-Age. A browser in use stays signed in; one left alone for 30 days does not.

It ends:

  • with auth.logout from that browser: the session and this browser's stay end and the answer clears the cookie (substrio_session=; ...; Max-Age=0). A remembered device stays.
  • for every browser of the person at once, with everything that ends every session (auth.logout_all, 2FA turned on or off, new recovery codes, a password change or reset, a lock, a suspension, the person's deletion), together with every remembered device.
  • after session_stay_seconds in substrio.conf (default 2592000, 30 days) with no trade.

The console keeps the token in the tab's memory, not in localStorage or sessionStorage. After sign-in it sends auth.refresh {"stay": true}. On each load it exchanges the cookie and shows the sign-in page only if that exchange is refused. It renews the token using the cookie at 80% of the token's lifetime. "Sign out" sends auth.logout_here.

Code Value HTTP When
TOKEN_INVALID 0xA001 401 auth.refresh with neither a session nor a live stay cookie
STORAGE_IO 0x1000 500 the stay could not be stored, renewed or ended: auth.refresh and auth.logout answer this in place of a session or a 204

Per request: an API call reads no cookie and pays nothing. A load pays one auth.refresh: one base64url read, one SHA-256 and one keyed read, plus one write about once a day for the slide. A sign-in pays one more request (the keep): 32 random bytes, one SHA-256 and one write.

# Keep the session in the browser's jar.
curl -c jar -X POST $SUBSTRIO_URL/v1/_auth/refresh -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $TOKEN" -d '{"stay": true}'
# Later, with no token: the cookie answers a fresh session.
curl -b jar -c jar -X POST $SUBSTRIO_URL/v1/_auth/refresh -H "X-Substrio-Tenant: my-app"
# {"success":true,"token":"..."}
# Sign out here: the stay ends and the cookie is cleared.
curl -b jar -c jar -X POST $SUBSTRIO_URL/v1/_auth/logout -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $NEW_TOKEN"

An account can require MFA of its members (PUT /v1/_accounts/{id}/mfa {"required": true}). A member without it then gets 403 {"error": "mfa_enrollment_required", "challenge": ...} at the identity app and enrols with that challenge in place of a session (challenge in the body of /v1/_auth/mfa/totp and /confirm); the confirm returns the session. The identity app's sign-in page (/oidc/signin) and the agent consent page (/oauth/consent) ask for the code; a consent is answered only by a session that passed MFA, so apps signed in through POST /v1/_auth/substrio trust the identity app's 2FA and do not ask again.

Using Tokens

Include the token in subsequent requests:

Authorization: Bearer <token>

The server looks the token up on each request, which is why a logout takes effect immediately.

The mail function: email_function

Substrio does not send mail directly. Set email_function in [auth] to name one of your functions. Substrio calls it when a sign-in flow needs an email, such as a password reset, email verification, or account invite.

[auth]
email_function = "send_auth_mail"
reset_url = "https://app.example/reset?token={token}"
verify_url = "https://app.example/verify?token={token}"

The function is called with one event (its event argument) shaped like this:

{"kind": "password_reset", "email": "[email protected]", "token": "substrio_pr_...",
 "link": "https://app.example/reset?token=substrio_pr_...", "expires_at": 1791378035,
 "app": "my-app", "user_id": 1}
Field Meaning
kind password_reset, email_verify or account_invite.
email Where to send it.
token The single-use token; it is redeemed by POST /v1/_auth/password/reset or /v1/_auth/email/verify with this value.
link reset_url (password reset) or verify_url (verification) with {token} replaced by the token; empty when the setting is empty. An invite's link is built from invite_url.
expires_at When the token stops working, in Unix seconds (reset_token_seconds, verify_token_seconds).
app The app's slug.
user_id The user the token was issued to (not for account_invite).
account_name, inviter_email, role Only for account_invite, in place of user_id.

For a password reset, verification, or invite, Substrio does not wait for or read the function's result. A failure in the function is not returned to the person who made the request. A sign-in code request (auth.email.code_request) does wait for the function, so the function can refuse it.

Refusing a sign-in code: RECIPIENT_UNSUBSCRIBED

A person who turned your app's email off (an unsubscribe link, for example) gets no code. The function says so by returning a 409 Response:

return new Response(JSON.stringify({
  code: "RECIPIENT_UNSUBSCRIBED",
  resubscribe_url: "https://mail.example/u/a42.b2ZmQGV4YW1wbGUuY29t.c2lnbmVk",
}), { status: 409, headers: { "content-type": "application/json" } });

The code request returns the refusal and the function's link. Your sign-in page can show the person how to turn email back on. The console and server sign-in pages for OAuth consent and OpenID sign-in already show "Turn this app's email back on" as a link to resubscribe_url (HTTPS only). The linked page asks the person to confirm:

{"error": "You turned off this app's email, so no code was sent. Ask for a link to turn it back on.",
 "code": "RECIPIENT_UNSUBSCRIBED", "status": 409, "retryable": false, "next_action": "inspect_detail",
 "resubscribe_url": "https://mail.example/u/a42.b2ZmQGV4YW1wbGUuY29t.c2lnbmVk"}
Code Value HTTP next_action error When
RECIPIENT_UNSUBSCRIBED 0xA024 409 inspect_detail You turned off this app's email, so no code was sent. Ask for a link to turn it back on. the app's email function answered 409 with that code and an https:// resubscribe_url (at most 2,048 bytes)

Anything else the function does (any other answer, a 409 with another code or a link that is not https://, a throw, a timeout) answers the usual 202 {"accepted": true, "expires_at": ...}. SendJet's send_auth_mail answers this refusal for a person who unsubscribed from the app, and its link mails them a way to turn it back on. The refusal tells the caller that this address turned this one app's email off, which the sign-in page needs to say why no code came; it says nothing about whether the address has an account.

The code request waits for the function's run (its mail send included), but no server thread does: the run's completion answers the request. A 202 costs one compare of the run's status more than before; the reply is parsed only on a 409.

A reset request immediately returns 202 {"accepted": true} whether or not the email belongs to a user, so callers cannot probe for addresses. Only a known, active user receives email. Read failed sends in the function's logs (substrio logs --function send_auth_mail). Without an email_function, a request for a reset or verification email returns 409.

Roles

Role Inherits Grants
guest none limited public access
viewer guest read-only access
editor viewer create and modify documents
admin editor manage collections, users and settings
owner admin full control of the project

A role includes everything granted by the role it inherits. Create a custom role with role.create, specifying the inherited role (default viewer). You can also provide lists of operations it grants and denies for bookkeeping. The lists are stored and returned but are not interpreted; the inherited keyring determines what the role may do. See permissions.md for the keyring model.

curl -X POST $SUBSTRIO_URL/v1/_roles \
  -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support",
    "inherits": "viewer",
    "grants": ["data.read"],
    "denies": ["data.delete"]
  }'

Assign a role to a user with role.assign:

curl -X PUT $SUBSTRIO_URL/v1/_users/42/role \
  -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"role": "editor"}'

Check whether a user can perform an operation on a collection:

curl "$SUBSTRIO_URL/v1/_users/42/can?operation=write&collection=posts" \
  -H "X-Substrio-Tenant: my-app" \
  -H "Authorization: Bearer $SUBSTRIO_KEY"

The response is {"allowed": true} or {"allowed": false}.

Collection and record-level access

Use per-collection rules to control who may read, write, create, and delete documents. Declare rules with security.rules.set (POST /v1/_rules). collection.create can also set initial ACL flags with acl_flags. See permissions.md for rule precedence, the rules JSON shape, and the owner, auth, role, and allow values.

Authentication flow

A call to /v1/_auth/login returns an opaque session token when the password matches. Send it as Authorization: Bearer <token> on later requests. Each request looks up the token and refuses an expired or logged-out session. It resolves the session's user and role (GET /v1/_auth/me returns them to the client), then applies the collection and record ACLs for the operation. A request that fails either check returns 403 Forbidden.

AI control-plane credentials

An AI agent uses the same authentication contract as every other client. An agent working on one app should use a short-lived bearer session or the app's key and send X-Substrio-Tenant on every request. Its GET /v1/_tools response is partition-bounded and contains only admin_scope: tenant tools.

After authentication, an agent discovers /v1/_capabilities, then /v1/_tools, and invokes a discovered operation through POST /v1/_tools/invoke or its concrete OpenAPI route. Tool operation IDs, typed route captures, grant bits, and structured error codes are authoritative: an agent must not infer permissions or next steps by matching response text. See agents and MCP for the full workflow and credential-handling rules.