Docs

Reference

Sign-in, sessions, multi-factor, one login across apps, roles, rules, the permission catalog and route gates.

Authentication & Authorization

Substrio includes a complete auth system with user management, token-based authentication, role-based access control (RBAC), and collection/record-level ACLs.

Overview

Client → session token → Server looks up the session → RBAC check → Access granted/denied

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 dev; 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 http://localhost:9000/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 http://localhost:9000/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"}

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 http://localhost:9000/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 that used to read role or isSystemAdmin from a decoded token reads them from this answer instead (substrio op auth.me is the same call). A key is not a user: /v1/_auth/me with a tenant or account key answers 403.

One login across apps

A person signs in once, at the identity app (substrio.conf identity_tenant), 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 http://localhost:9000/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. An app decides what each role may do.

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 (a customer's key, or the operator's own) 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 operator 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). The check is one load and one compare, paid only on those routes.

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, so the handler asks the platform on the value it read. It still decides nothing itself:

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 the platform answered holds: true. Otherwise the call is answered there with the platform's own refusal, the one 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, with no seek) 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. One property read and one compare; no seek.

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 seek 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 seek a call, none when the optional parameter is absent.

A handler that found a person by a seek (an address, a field the app declared on _users) and needs that person's number, the one request.auth.user_id holds, asks the platform for it:

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: the platform keys a person 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, an operator's POST /v1/_users), 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. substrio.conf account_personal = false turns this off.

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.

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 http://localhost:9000/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 http://localhost:9000/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 http://localhost:9000/v1/_auth/mfa -H "X-Substrio-Tenant: my-app" \
  -d '{"challenge": "substrio_mfa_...", "code": "123456"}'
  • A challenge lives mfa_challenge_seconds (substrio.conf, default 300), works once, and is spent after mfa_attempts wrong codes (default 5).
  • 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 second factor 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.

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

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

  1. Client calls /v1/_auth/login with credentials
  2. Server validates the password and returns an opaque session token
  3. Client includes Authorization: Bearer <token> on requests
  4. Server looks the token up (a logged-out or expired one is refused)
  5. Server resolves user session and role (GET /v1/_auth/me returns them to the client)
  6. RBAC check against collection/record ACLs
  7. Request proceeds or returns 403 Forbidden

Master Key

The master key bypasses all RBAC checks. Use it for:

  • Admin operations
  • CI/CD pipelines
  • Cloud Function deployment
  • Initial setup

Never expose the master key in client-side code.

AI control-plane credentials

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

A system controller should use the process master key (or another API-key-authenticated global-admin session) only in a protected control service. Its tool manifest contains the tenant tools plus tenant lifecycle, identity-provider lookup/update/delete, network-allowlist, request-limit, security-audit, and mesh-read controls. The controller selects a tenant explicitly before using tenant tools; the global credential does not merge tenants' data, files, caches or audit logs.

After authentication, an agent should discover /v1/_capabilities, then /v1/_tools, and invoke 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; models must not infer permissions or next steps by matching response text. See AI agent API guide for the full two-level workflow and credential-handling rules.

Substrio Permission System

Substrio uses a 4-layer permission stack that evaluates top-to-bottom. Each layer is O(1): a single bitmask AND or flag check per request.

┌─────────────────────────────────────────────┐
│  Layer 1: Master Key                        │  Bypass everything
├─────────────────────────────────────────────┤
│  Layer 2: 64-bit Fractal Keyrings (RBAC)    │  Role → compiled keyring → AND check
├─────────────────────────────────────────────┤
│  Layer 3: Security Rules (Declarative)      │  Per-collection deny/auth/role/owner flags
├─────────────────────────────────────────────┤
│  Layer 4: Record-Level ACL (per-document owner)│  Per-document owner isolation
└─────────────────────────────────────────────┘

Layer 1: Master Key

The master key bypasses all permission checks. It is the ultimate admin override.

  • Auto-generated on first server boot → {data_dir}/substrio.conf
  • Per-tenant master keys are also generated at tenant creation → {data_dir}/{tenant}/substrio.conf
  • Send as header: X-Substrio-Master-Key: <key>
  • Both global and per-tenant keys are accepted for tenant-scoped requests
curl -H "X-Substrio-Master-Key: your-key" \
     -H "X-Substrio-Tenant: my-app" \
     http://localhost:9000/v1/posts

Never expose the master key in client-side code. Use it only for server-side admin operations, CI/CD, and Cloud Functions.


Layer 2: Role-Based Access Control (Keyrings)

Every user has a 64-bit fractal keyring: a pre-compiled bitmask encoding their permissions across project, collection, record, and operation dimensions.

Built-in Roles

Role Keyring Capabilities
guest Read-only on public collections Browse public data
viewer Read/list/query on all collections Read-only access
editor Full CRUD on all collections Create, read, update, delete
admin CRUD + admin ops + subscribe + execute Manage collections, users, settings
owner Superadmin (all bits set) Full control of tenant

Role Hierarchy

owner inherits admin inherits editor inherits viewer inherits guest

A role holds everything the role it inherits holds: an admin passes a rule that asks for role:editor or role:viewer; a viewer does not pass role:editor. A custom role (role.create) names the role it inherits with inherits (default viewer) and is a role record only: it makes no collection. A person who was never assigned a role holds none, and passes no role:<name> rule (the tenant's first user holds owner).

Assigning Roles

# Assign "editor" role to user 42 (operation role.assign, MCP tool substrio_role_assign)
curl -X PUT http://localhost:9000/v1/_users/42/role \
  -H "X-Substrio-Master-Key: <key>" \
  -H "X-Substrio-Tenant: my-app" \
  -d '{"role": "editor"}'

With the CLI: substrio op role.assign id=42 role=editor.

How Keyrings Work

64-bit keyring:
┌──────────┬───────────┬──────────┬───────────┐
│ Project  │Collection │  Record  │ Operations│
│  16 bits │  16 bits  │ 16 bits  │  16 bits  │
└──────────┴───────────┴──────────┴───────────┘

Permission check: (user_keyring & required_keyring) != 0  →  1 CPU cycle

Roles compile to keyrings at assignment time; the keyring gates a collection that has no rule. A collection's role:<name> rule is decided by the role a person holds, not by the keyring. A keyring on the wire is a 0x hex string (a 64-bit value does not survive a JSON number).


Layer 3: Security Rules (Declarative)

Security rules let you define per-collection access policies declaratively via API. Rules compile down to existing ACL flag bitmasks: zero per-request evaluation cost.

Setting Rules

curl -X POST http://localhost:9000/v1/_rules \
  -H "X-Substrio-Master-Key: <key>" \
  -H "X-Substrio-Tenant: my-app" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": [
      {
        "collection": "posts",
        "read": "allow",
        "write": "role:editor",
        "create": "auth",
        "delete": "role:admin"
      },
      {
        "collection": "admin_logs",
        "read": "role:admin",
        "write": "deny",
        "create": "deny",
        "delete": "deny"
      },
      {
        "collection": "*",
        "read": "allow",
        "write": "auth",
        "create": "auth",
        "delete": "auth"
      }
    ]
  }'

Access Levels

Level Behavior
allow Everyone, a caller with no session included (public)
auth Must be signed in (have a valid token)
deny Blocked entirely (403 Forbidden)
owner Must own the document
role:<name> Must hold role <name> or a role that inherits it: role:viewer admits viewer, editor, admin, owner; role:admin admits admin and owner; a custom role role:host admits host and the roles that inherit host

An operation a rule does not name follows the defaults (it is not public); only an explicit allow is. An unknown value (a typo) is refused when the rules are set. A role rule asked of a caller with no session answers 401; a signed-in caller without the role gets 403.

Operations

Each access level can be applied independently to four operations:

Operation HTTP Methods Description
read GET Query, fetch documents
write PUT, PATCH Update existing documents
create POST Insert new documents
delete DELETE Remove documents

SDK Usage

const db = new Substrio({ url, tenant, key });

// Set rules
await db.rules.set({
  rules: [
    { collection: 'posts', read: 'allow', write: 'role:editor', delete: 'role:admin' },
    { collection: 'settings', read: 'role:admin', write: 'role:admin' },
  ]
});

// Get current rules
const config = await db.rules.get();
console.log(config.rules);

// Remove all rules
await db.rules.del();

How it behaves

  1. Rules are checked on every request. When you set rules (security.rules.set, POST /v1/_rules, substrio op security.rules.set, or collections/<name>.json in your project), they apply to the next request.
  2. Order of checks: deny answers 403 Forbidden; allow passes everyone; auth needs a signed-in caller; role:<name> needs that role (or one that inherits it). The same check runs on every path that reads or writes the collection for an end user: data operations, queries, counts, aggregates, chains and joins, searches, batches, imports and exports, and realtime subscriptions. The rules are compiled once into flag bits; a role rule reads the caller's role and walks its inheritance chain.
  3. Rules persist across server restarts.
  4. The master key bypasses every rule.

Layer 4: Record-Level ACL (per-document owner)

For owner-gated collections, each document is tagged with the creator's unique bitmask. At query time, only documents whose ACL mask matches the caller's mask are returned.

Document ACL check: (doc_acl_mask & caller_mask) != 0  →  1 CPU cycle per document
  • Master key: caller_mask = UINT64_MAX (matches everything)
  • Regular user: caller_mask = unique_bit_for_user
  • Applied in all query paths: $knn, $search, scalar, $count, pagination

Permission catalog

Beyond the built-in roles, a tenant can declare its own named permissions (substrio deploy's [permissions] table, operation permissions.set): up to 64 names, each one bit. A name can imply others, and one name can be declared all.

[permissions]
names = ["READ", "WRITE", "ADMIN"]
all = "ADMIN"

[permissions.implies]
ADMIN = ["WRITE"]
WRITE = ["READ"]

The catalog is append only. A later deploy may add names at the end; it may never reorder, rename or remove one, since a stored name's bit never changes meaning once declared. A deploy that tries is refused, naming the position at fault, and the stored catalog is left exactly as it was. A deploy with no [permissions] table also leaves it exactly as it was (it does not clear it).

all closes to every bit, including one declared later. all does not mean "every name declared today": it closes to all 64 bits, so a grant of all made before a name is appended still holds that name once it exists, with nothing stored rewritten.

substrio permissions (or operation permissions.get) answers the stored catalog:

{
  "names": [
    { "name": "READ", "bit": 0, "implies": [] },
    { "name": "WRITE", "bit": 1, "implies": ["READ"] },
    { "name": "ADMIN", "bit": 2, "implies": ["READ", "WRITE"] }
  ],
  "all": "ADMIN"
}

Each name's implies is its closure: every name it implies, transitively, itself excluded.

A grant is stored as the names given, never closed. Implications are applied once, at sign in, when a session's keyring is built: the OR of the held names' closures. A later change to implies takes effect at the next sign in, not by rewriting any stored grant. A permission check at request time is then one AND against that already-closed mask ((held & (1 << bit)) != 0): no catalog lookup and no closure walk on the request path.


Enforcement Priority

1. Master key?        → BYPASS ALL (Layer 1)
2. Security rule set? → allow/auth/role/deny per operation decides (Layer 3);
                         a collection with no rule for the operation falls to
3. Keyring check?     → does user's role keyring hold the operation? (Layer 2)
4. Owner-only?        → filter docs by caller's ACL bitmask (Layer 4)

Every check is a flag test, except a role:<name> rule: it reads the caller's one role record (and, for a custom role, its parent records). No policy evaluation, no expression parsing.


Platform Collections

A platform collection (currently only _users) is owned by Substrio, not an app: it exists with no collection.create call, carries a platform-declared schema the app cannot weaken (it may add its own fields and indexes, but not redeclare a platform field with a different type or drop email's uniqueness), and refuses collection.create/collection.delete. It is listed by collection.list like any other collection, marked "platform": true.

Layer 3's default differs for a platform collection: an ordinary collection with no declared rule falls through to Layer 2 (the keyring); _users with no app-declared rule is deny for every operation (read/write/create/delete), for every non-admin caller, signed in or not. An app opts in by declaring its own rule for _users (collections/_users.json's rules, or POST /v1/_rules), exactly as for any other collection. The master key always bypasses this, as it does every rule (Layer 1).

A function always acts as the app (never as an HTTP caller), so this default-deny never applies to a function's own access to _users.