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) live 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 and substrio push; 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 is its own code, and its error is the sentence to show the person as it is:

{"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
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

A failure costs no string on the server: the reason is a 2-byte code, and the answer is that code's row.

The session token is opaque, not a JWT

The token is one opaque string (no dots, no header, no claims): nothing in it can be decoded in the browser. It names a session the server holds, so a logout ends it at once, which a signed, self-contained token could not do. Never parse it; keep it and send it back.

What a JWT would have carried comes from one call, made 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, "superadmin": false, "source": "native",
 "created_at": 1791374435, "last_login": 1791374435}

The user's id, email, whether the email is verified, and the role flags (admin, superadmin, keyring) are the claims an app needs. A client reads role or isSystemAdmin from this answer, not from a decoded token (substrio op auth.me is the same call). A key is not a user: /v1/_auth/me with an app key or an account key answers 403.

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 /oidc/authorize with an S256 PKCE challenge, then trades the code on the callback for a session of its own:

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": "..."}'

It answers like a password login (success, user_id, token, keyring). The code is spent in process and works once. The app's user is found by its link to the identity user, then by the identity app's verified email (a user who signed up with a password is linked, not duplicated), or 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 any other app your account owns: present it there and it is treated as the account's own key, admin of that app, whatever role (owner, admin or developer) you hold in the account. A token from any other app, or an account you hold no role in, is refused.

A function never reads a person's permissions itself. A route that needs to know whether its caller holds one declares it in its gate: "GET /hosts/mine auth?ADMIN" is an auth route (a caller who is not signed in is refused with 401) that also asks, and refuses nobody for the name. The platform answers in request.auth.holds: true when the session holds ADMIN (directly, through a permission that implies it, or through the catalog's all name), false otherwise. A route that asks nothing has no holds member, and neither has a call made with an account key (a key is not a person and holds no permissions). A name the permission catalog does not declare fails the deploy. "POST /notes public?NOTES_EDIT" is the same asking, on a route that refuses nobody (public's own 401 is never given): a signed-in caller gets holds the same way, and request.auth is null with no credential, as public always answers.

An account key scoped to the app calls auth and verified routes: the app's consumers call its keyed API. A key holds no permission, so an auth:<name> route answers a key only when it belongs to the account that owns the app; any other key gets 403 PERMISSION_DENIED. "GET /contact-requests owner" is the gate for what only the app's owner may read: it answers a key of the owning account and nobody else (401 CREDENTIAL_REQUIRED with no credential, 403 PERMISSION_DENIED for any other 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 the catalog does not declare is a TypeError when the module loads). db.requirePermission(permission, request.auth, organization) takes the organization's document id as text, the value of the reference a membership names. It returns when the caller's membership in that organization holds the permission, or when the route asked auth?<that name> (or the catalog's all name) and holds answered true. Otherwise the call is answered there with the same refusal a route gate gives: 403 PERMISSION_DENIED, or 401 CREDENTIAL_REQUIRED when nobody is signed in. It is an answer, not a failed function: nothing is recorded as a function error, and code after the call does not run. Do not catch it. A row that names no organization is held by nobody but a holder everywhere. db.requirePersonOrPermission(person, permission, request.auth, organization) passes a row's own person (request.auth.user_id === person) as well as anyone db.requirePermission would: a route whose handler may act on what it owns or on a permission others hold, in one call. db.requireVerified(request.auth) is the verified route gate as a call, for a handler whose need for a proven address depends on a row it reads: it passes a signed in person whose session carries the verified flag (request.auth.email_verified) and an app key, and answers 401 CREDENTIAL_REQUIRED for nobody 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 the identity app creates, by any path (a password sign-up, a social or identity-app sign-in), gets an account of their own at once, with them as its owner and personal: true, named after their display name or their email's local part. It is made once and never again; it goes with them when they are hard-deleted while it has no other member and owns no apps.

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

A new account holds 500 MB across all its projects. It opens to its plan's space (Free: 2 GB) the first time its owner signs in with Google, GitHub or another provider. An email code, a password, a passkey or an agent's console link does not open it: an email address costs nothing to make, and a provider account is harder to fake.

  • 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.

The console signs a person in by email, with no password. Its first step asks for one thing, the email address, and its button is "Send login link". One auth.email.code_request at the identity app mails the person a link and a six-digit code together, and the next step, "Check your email", waits for either:

  • 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 in on its own with one auth.email.code_verify {email, code}. There is no button to press.

A person nobody held yet (created: true) is made and signed in at once, with no 2FA asked at sign-up; the Customer page offers it later. A person with 2FA enabled gets its challenge under mfa instead of a token, and the console asks for that code (or sets one up, when the account requires it).

"Send again" requests a new pair. Inside the app's request interval the request is refused 429, which the console reads as "We just sent one. Check your inbox." "Use a different email" goes back to the first step.

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.

An agent that signed its person up (the mailed code: auth.email.code_verify or agent.signup.finish) can open the person's browser straight onto their dashboard, signed in, without a password. It mints a one-time link and opens 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

Only a person mints a link: an agent session, for the member it acts as, in its account; or the person signed in to the identity app as themselves. A key (an app's key, an account or agent key) and an agent token are no person and are refused CONSOLE_LINK_NEEDS_PERSON.

The link signs in once and lives 5 minutes. A new link for the same person replaces the last, so a person holds one live link. Only the token's SHA-256 is stored, under the person's id. Nothing is added to any other request.

The token rides in the URL's fragment (#link=), which a browser never sends to a server, so no request line, access log or Referer carries it. The console reads the fragment once, removes it from the address (history.replaceState), redeems it and lands signed in. The redeem answers what auth.email.code_verify signs in with: {user_id, account_id, token, keyring}; for a person with 2FA enabled (or whose account requires it) it answers the 2FA challenge under mfa instead of the token, which auth.mfa trades for the session as after a password.

A session a link signed in is the person, with an agent key's approvals. Its token carries a signed mark (a bit of its signed flags byte, so it cannot be stripped; a refresh keeps it). With it:

  • 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 opens in the account the call acts in (the app's account, or the account the route names) and names the person and the session's token, so the person, or an admin or owner of that account, decides it. A refresh is a new token, so a call waiting before it is asked again after it.

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"}'

From then on a sign-in (password or social) answers 401 with {"error": "mfa_required", "challenge": "substrio_mfa_...", "expires_at": ..., "attempts_left": ...} and no session. The challenge and a code, or a recovery code, are traded 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 lives 5 minutes, works once, and is spent 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.

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 itself. email_function in [auth] names one of your functions, and substrio calls it whenever a sign-in flow needs a mail sent: a password reset, an email verification, an 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, a verification or an invite, what the function returns is not read: substrio does not wait for it, and a failure inside it does not reach the person who asked. A sign-in code (auth.email.code_request) is the one mail whose answer waits 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 then answers that refusal, carrying the function's link, which your sign-in page shows the person ("You turned off our email. Send me a link to turn it back on."). Sign-in in the console and the sign-in pages the server serves (OAuth consent, OpenID sign-in) already do: under the refusal they show "Turn this app's email back on", a plain link to resubscribe_url (https only), whose 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 answers 202 {"accepted": true} at once, whether or not the email belongs to a user (so an address cannot be probed), and only a known, active user gets a mail. Read a failed send in the function's logs (substrio logs --function send_auth_mail). With no email_function set, a request for a reset or verification mail answers 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 holds everything the role it inherits holds. Create a custom role (role.create) naming the role it inherits (default viewer) and, for your own bookkeeping, the operations it grants and denies: these two lists are stored and returned but never interpreted, since what a role may actually do comes from its inherited keyring, not from these strings. 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"

That answers {"allowed": true} or {"allowed": false}.

Collection and record-level access

Collection access compiles down to ACL flag bitmasks (read_keyring, write_keyring, list_keyring, create_keyring, and per-operation deny/auth/role/owner/allow flags in CollectionACL), not a JSON list of role names. collection.create's optional acl_flags sets the starting bits, and declarative per-collection rules (security.rules.set, POST /v1/_rules) are compiled into the same flags with zero per-request evaluation cost. See permissions.md for the full layer model, the rules JSON shape, and the owner/auth/role/allow rule values.

Authentication flow

A call to /v1/_auth/login with credentials returns an opaque session token once the password matches. Send that token as Authorization: Bearer <token> on every later request. Each request looks the token up (a logged-out or expired one is refused) and resolves the session's user and role (GET /v1/_auth/me returns them to the client), then checks the collection and record ACLs for the operation: a request that fails either check answers 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.