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_secondsdefaults to 3600 seconds (one hour)./oidc/tokenreturns it asexpires_in. The token is refused when it expires. - Sign-out. Signing the person out at the identity app (
POST /v1/_auth/logout, orlogout_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/userinforeads the person on every call: one who has been disabled, suspended or deleted gets 401invalid_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'ssubstrio_sessioncookie (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, whenlogin_hintnames 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}withoutapprovereturns the code when the person has allowed the app before. Otherwise it returns 400invalid_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.logoutfrom that browser clears the cookie and ends every app's session at its next refresh.auth.logout_alldoes 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 withPOST /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.catalogbelow). Microsoft signs in work, school and personal accounts (thecommonendpoint). 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 itsredirect_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 inclient, and the providers it turned off are left out. Aclientthat 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 withc.goes to the console sign-in at substrio.com. Any other goes to/oidc/signinatid.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_tokenwithresponse_mode=form_post. The route athttps://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":"..."}toPOST /v1/_auth/oauth, which checks the token against Apple's keys and the registration's Services ID. - Microsoft and an existing account: Microsoft's
mailis 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 claimsemailandxms_edovto 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.
0removes 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.
Console sign-in: a link, or the code beside it
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'sverify_urlishttps://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 oneauth.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}
A console sign-in link from an agent: account.console.link
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 answerCONSOLE_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 takemfa_attemptswrong codes in a row per user (attempts_leftin each401); then both answer429mfa_locked, whatever the code, formfa_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.
-
Remember.
auth.mfa(POST /v1/_auth/mfa) with"remember": true, finishing a challenge thatauth.email.code_verifyanswered (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=2592000The 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;
rememberis false when absent. The console sendsremember: trueunless the person unticks "Remember this device for 30 days". -
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.mfafinishes 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. -
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_secondsinsubstrio.conf, also the cookie'sMax-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"}'
Staying signed in: 30 days in the browser's cookie
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.
-
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=2592000The 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.
-
Trade.
auth.refreshwith noAuthorizationheader 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, answers401TOKEN_INVALID("Bearer token required"), the bytes every refresh with no credential gets. -
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.logoutfrom 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_secondsinsubstrio.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.