← Back to API docs

Docs

Auth API

The Auth API gives your app's end users accounts and identity tokens. A browser or mobile client signs in against an auth instance, gets back an id_token, and presents that token straight to the Datastore and Channel APIs — so a static site can read and write real data with no backend of your own. What each end user is allowed to see and change is decided by access rules you configure on the auth instance, enforced on every request. All paths below are relative to an auth instance you provision in the console.

How it fits together

  1. You provision an auth instance, choose which fields the sign-up form collects, and write access rules naming the datastore and channel instances its users may reach.
  2. A client signs up or signs in and receives an id_token plus a refresh_token.
  3. The client sends Authorization: Bearer <id_token> to /v1/datastore/… and /v1/channel/…. Your rules are applied to every read and write.

Your organization API key is never involved in that loop and never reaches the browser. An identity token can only reach instances in the same organization as the auth instance that issued it, and only the ones your access rules name.

Authentication and base URL

Send requests to https://api.altengine.net. Every path is scoped to an auth instance by its instance id: /v1/auth/{instance-id}/…. The id is the globally unique one shown on the instance's page in the console, not the name you gave it — the name addresses the instance on other services, and this plane does not accept it. The id is public and belongs in your client code.

Unlike the other services, the auth endpoints are public: they take no organization API key. Endpoints that act on an already-signed-in user (/me, 2FA enrolment, passkey management) take the user's own id_token as a bearer token instead.

Authorization: Bearer <id_token>

Two abuse limits apply. Per client, auth endpoints allow roughly one request per second with a small burst. Per instance, the auth plane shares the standard 200 requests per second connect budget, which you can lower in the console (Auth → your instance → Settings). Over either limit a request returns 429 with a RATE_LIMITED code; back off and retry.

You can also restrict which browser origins may call the instance. When the allowlist is non-empty, a request whose Origin is not listed is refused and gets no CORS grant. Leave it empty to allow any origin. It is a hardening measure, not the trust boundary — the token is.

Endpoints

Auth API endpoints
Method & pathAuthPurpose
GET /v1/auth/{instance-id}/configPublicClient-safe sign-in config: the sign-up fields, which methods are on, the CAPTCHA site key.
GET /v1/auth/{instance-id}/embed.jsPublicThe drop-in embeddable auth form.
POST /v1/auth/{instance-id}/signupPublicCreate an account and sign in.
POST /v1/auth/{instance-id}/signinPublicSign in with the identity field and a password.
POST /v1/auth/{instance-id}/token/refreshRefresh tokenExchange a refresh token for a fresh id_token (rotates the refresh token).
POST /v1/auth/{instance-id}/signoutRefresh tokenRevoke a refresh token.
GET /v1/auth/{instance-id}/meid_tokenThe signed-in user's profile and claims.
POST /v1/auth/{instance-id}/passwordless/startPublicEmail a one-time sign-in code.
POST /v1/auth/{instance-id}/passwordless/verifyPublicExchange that code for tokens.
POST /v1/auth/{instance-id}/password/reset/startPublicEmail a one-time password-reset code.
POST /v1/auth/{instance-id}/password/reset/verifyPublicSet a new password with that code.
POST /v1/auth/{instance-id}/email/verify/startPublicEmail a code to verify an address (when verification is required).
POST /v1/auth/{instance-id}/email/verify/confirmPublicConfirm the code, verify the address, and receive tokens.
POST /v1/auth/{instance-id}/2fa/totp/startid_tokenBegin authenticator-app enrolment; returns the secret and an otpauth URI for a QR code.
POST /v1/auth/{instance-id}/2fa/totp/confirmid_tokenConfirm enrolment with a current code — 2FA becomes active.
POST /v1/auth/{instance-id}/2fa/totp/disableid_tokenTurn 2FA off; requires a current code.
POST /v1/auth/{instance-id}/2fa/verifyMFA challengeComplete a 2FA sign-in and receive tokens.
POST /v1/auth/{instance-id}/passkey/register/startid_tokenBegin adding a passkey to the signed-in account. Takes totp_code when the account has 2FA on.
POST /v1/auth/{instance-id}/passkey/register/finishid_tokenFinish adding the passkey.
POST /v1/auth/{instance-id}/passkey/login/startPublicBegin passkey sign-in; returns the browser's challenge options.
POST /v1/auth/{instance-id}/passkey/login/finishPublicFinish passkey sign-in and receive tokens.
GET /v1/auth/{instance-id}/passkeysid_tokenList the signed-in user's passkeys.
DELETE /v1/auth/{instance-id}/passkeys/{credId}id_tokenRemove one of the user's passkeys.

The sign-up form is configuration

An auth instance declares which fields it collects at sign-up and which one of them is the unique login identity. Switching between an email-and-password app and a username-and-password app is a config change (console → Auth → your instance → Sign-up form).

The identity field's value is the account's login handle and must be unique; it must be required, and typed email or string. Every other field becomes part of the user's profile, which you can read back from the token and use inside access rules as $auth.profile.<key>. Because the user typed these values themselves, they are self-asserted — safe to display or stamp onto a document, but never authoritative for an access decision (see Profile vs. claims below). A password is always required and is never one of the fields.

{
  "identityField": "email",
  "fields": [
    { "key": "email", "type": "email",  "required": true,  "label": "Email" },
    { "key": "name",  "type": "string", "required": true,  "label": "Display name" },
    { "key": "team",  "type": "string", "required": false, "label": "Team" }
  ]
}

Field types are email, string, number, and boolean. Up to 20 fields. To make the same app username-based instead, set "identityField": "username" and give username a string field — email then becomes an ordinary optional profile field.

Rules for sign-up form fields and the values users submit
WhatRule
Field keyStarts with a letter; letters, digits and _; up to 40 characters; unique ignoring case. Reserved: uid, password, identifier, disabled, created, updated, claims, iss, sub, exp, iat.
Field labelOptional, up to 60 characters.
email valueTrimmed and lowercased; must look like [email protected]; up to 320 bytes.
Username (a string identity field)Trimmed and lowercased; 1–64 characters of a–z, 0–9, ., _, -, starting with a letter or digit. Ada.L and ada.l are the same account.
string valueTrimmed; up to 512 characters.
number valueA number, or a string that parses as one.
boolean valuetrue or "true"; anything else is stored as false.
Whole profileUp to 4 KB as JSON. Claims have the same cap.

A required field that is missing or blank returns 400 INVALID_ARGUMENT naming the field's label. Signing up with an identity value that is already taken returns 409 ALREADY_EXISTS.

Clients don't need to hard-code any of this. GET /config returns the form shape, so a sign-in screen can render itself:

GET /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/config
# → {
#     "instance": "3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13",
#     "allow_signup": true,
#     "identity_field": "email",
#     "fields": [ { "key": "email", "type": "email", "required": true }, … ],
#     "captcha": { "enabled": false, "site_key": "" },
#     "methods": { "password": true, "passkeys": true,
#                  "passwordless": true, "totp": false }
#   }

Sign-in methods

Four methods are available; each one is a per-instance toggle except password, which is always on. All of them end at the same place — a token pair.

Password

Sign up with the configured fields plus a password of 8 to 256 characters, or sign in with the identity value and the password. The same length rule applies wherever a password is set: sign-up, password reset, and accounts created from the console or MCP.

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/signup
{ "email": "[email protected]", "name": "Ada", "password": "correct horse battery" }
# → 201 { "id_token": "…", "refresh_token": "…",
#          "expires_at": 1783036800, "refresh_expires_at": 1785542400,
#          "user": { "uid": "…", "identifier": "[email protected]",
#                    "profile": { "name": "Ada" }, "claims": {}, … } }

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/signin
{ "identifier": "[email protected]", "password": "correct horse battery" }
# → { "id_token": "…", "refresh_token": "…", "expires_at": …, "user": { … } }

A wrong password and a non-existent account return the same 401, so sign-in never reveals which handles are registered. Public sign-up can be turned off per instance, in which case POST /signup returns 403 and accounts are created from the console instead.

Passwordless (emailed one-time code)

Email a six-digit code and exchange it for tokens. The code is single-use and short-lived (5–30 minutes, 10 by default), and only one live code exists per user at a time.

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/passwordless/start
{ "identifier": "[email protected]" }
# → { "ok": true }          always 200 — never reveals whether the account exists

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/passwordless/verify
{ "identifier": "[email protected]", "code": "418207" }
# → { "id_token": "…", "refresh_token": "…", "user": { … } }

While a code is still live, another /start for the same purpose sends nothing (it still answers 200). A new code can be requested once the old one expires, is used, or has taken 5 wrong guesses.

Password reset uses the same code flow, at /password/reset/start and /password/reset/verify. The verify call also takes new_password and returns { "ok": true }, not tokens — the user then signs in with the new password.

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/password/reset/verify
{ "identifier": "[email protected]", "code": "418207", "new_password": "…" }
# → { "ok": true }

Setting a new password ends every other session and removes every passkey on the account: refresh tokens stop working at once, identity tokens issued before the reset are revoked, and the user adds their passkeys again. An id_token issued before the reset can no longer add or remove a passkey or change 2FA — those calls answer 401 REAUTH_REQUIRED until the user signs in again.

A disabled account cannot reset its password. /password/reset/start sends it nothing (and still answers 200); a correct code issued before the account was disabled gets 403 PERMISSION_DENIED from /password/reset/verify, and the password is not changed.

Two-factor authentication (TOTP)

A signed-in user enrols an authenticator app: /2fa/totp/start returns a secret and an otpauth:// URI you can render as a QR code, and /2fa/totp/confirm activates 2FA once they type a current code. Turning it off requires a current code too.

Once 2FA is active, sign-in becomes two steps. /signin (and passwordless verify) return a challenge instead of tokens:

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/signin
{ "identifier": "[email protected]", "password": "…" }
# → { "mfa_required": true, "mfa_token": "…" }

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/2fa/verify
{ "mfa_token": "…", "code": "302914" }
# → { "id_token": "…", "refresh_token": "…", "user": { … } }

Always branch on mfa_required in your sign-in handler — it is the one response shape that carries no id_token.

Passkeys

Passkeys use the browser's built-in WebAuthn support, so a user signs in with a fingerprint, face, or device PIN. Registration requires a signed-in user; sign-in does not. Each ceremony is two calls — a start that returns options for navigator.credentials plus an opaque state, and a finish that you post the browser's response and that same state back to.

// Register a passkey for the signed-in user
const start = await post("/passkey/register/start", {}, idToken);
const cred  = await navigator.credentials.create({ publicKey: decode(start.options) });
await post("/passkey/register/finish", { response: encode(cred), state: start.state }, idToken);

// Sign in with a passkey (omit `identifier` to let the browser offer any passkey)
const s = await post("/passkey/login/start", { identifier: "[email protected]" });
const a = await navigator.credentials.get({ publicKey: decode(s.options) });
const { id_token } = await post("/passkey/login/finish", { response: encode(a), state: s.state });

Passkeys are scoped to your own site's domain — the browser will not offer them anywhere else. Passkey sign-in issues tokens directly, with no separate 2FA step, so adding a passkey to an account with 2FA on takes a current code: send { "totp_code": "…" } to register/start, or it answers 401 TOTP_REQUIRED. The user is emailed whenever a passkey is added. Users can list and remove their passkeys at GET /passkeys and DELETE /passkeys/{credId}. The embeddable form handles all of the encoding for you.

Require email verification

By default a new account can sign in immediately. Turn on Require email verification (console → Auth → your instance → Settings) to hold sign-in until the user confirms their address. When it's on:

  • Signup issues no session. POST /signup returns { "verification_required": true, "user": … } with no id_token, and emails a six-digit code.
  • Sign-in is gated. Even with the correct password, /signin returns 403 with code EMAIL_UNVERIFIED until the address is confirmed. (A wrong password still returns the same generic 401 as always, so the gate never reveals whether an account exists.)
  • Confirm to finish. /email/verify/confirm consumes the code, marks the address verified, and returns tokens — the user is signed in. /email/verify/start sends a new code (always 200, enumeration-safe) — but only once the sign-up code has expired, been used, or taken 5 wrong guesses; while it is live, nothing is sent.
POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/signup
{ "email": "[email protected]", "password": "…" }
# → { "verification_required": true, "user": { …, "emailVerified": false } }

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/email/verify/confirm
{ "identifier": "[email protected]", "code": "418207" }
# → { "id_token": "…", "refresh_token": "…", "user": { …, "emailVerified": true } }

A completed password reset or a passwordless sign-in also marks the address verified. Three things to know before enabling it:

  • Existing users are grandfathered. Turning the setting on marks every current account verified — only sign-ups from that point on must verify. The exception is an account that signed up while the setting was on and never confirmed: turning the setting off and on again leaves it unverified, and its next sign-in gets EMAIL_UNVERIFIED.
  • The instance must be able to email. You can't enable it on a username-only instance that collects no email field — the console rejects that combination.
  • It gates new sessions, not live ones. A user already holding a refresh token keeps refreshing; the gate applies at sign-in. And while it's on, /email/verify/{start,confirm} effectively offers inbox-based sign-in to any user, at the same trust level as password reset.

Transactional email

The reset, passwordless, verification, and (optional) welcome messages are sent from a per-instance address on our verified domain — YourName <auth-<instance-id>@altengine.net>. Set the display name (and, if you like, a custom local-part) and edit the messages under console → Auth → your instance → Email. The domain is always ours; sending from your own domain is not supported, so there is no DNS to set up.

Sender settings, their rules, and what is used when they are empty
SettingRulesWhen empty
From nameUp to 100 characters; no line breaks and no < > ".The sender name and {{app}} use the instance name. The issuer shown in authenticator apps is altengine.
From address local-partUp to 64 characters of letters, digits, ., _, -.auth-<instance-id>

Templates are simple fields, not raw HTML. For each message you set a subject and a short intro line; we render the code block, layout, and footer around them. Placeholders are filled and safely escaped for you: {{app}} (your display name), {{code}}, and {{ttl}}. A welcome email (off by default) can be enabled to greet new users on signup; it's best-effort and never blocks the account being created.

Tokens and refresh

A successful sign-in returns a token pair:

Fields returned on a successful sign-in
FieldMeaning
id_tokenThe identity token. Send it as Authorization: Bearer to Datastore and Channel. Short-lived. A signed JWT whose payload carries sub (the uid), identifier, profile, claims, exp and typ: "id"; only an id token is accepted where an identity token is expected.
refresh_tokenUsed only against this auth instance, to get a new id_token. Long-lived.
expires_atUnix seconds when the id_token expires.
refresh_expires_atUnix seconds when the refresh token expires.
useruid, identifier, the user's self-asserted profile, and their server-set claims.

The id_token lasts 1 hour by default (configurable from 5 minutes to 24 hours) and the refresh token 30 days (1 hour to 90 days).

POST /v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/token/refresh
{ "refresh_token": "…" }
# → { "id_token": "…", "refresh_token": "…", "expires_at": …, "refresh_expires_at": … }

Refresh rotates: the old refresh token stops working and you must store the new one. It also re-reads the account, so changed claims are on the next refresh's token; a disabled account's refresh answers 403. The practical pattern is to catch a 401 from a data request, refresh once, and retry — and send the user to sign in if the refresh fails too. POST /signout revokes a refresh token.

Revocation

These end a user's sessions before their tokens expire:

  • disabling the account (console, MCP, or env.auth.setDisabled in a function),
  • deleting it,
  • a completed password reset,
  • env.auth.revokeSessions in a function.

Refresh tokens stop working at once. Identity tokens already issued are refused — 401 UNAUTHENTICATED from Datastore, Channel, /me and GET /passkeys, and null from env.auth.verifyToken — at once in the location where the change was made, and within about a minute everywhere else. Re-enabling a disabled account does not bring its old tokens back; the user signs in again.

Access rules

Access rules live on the auth instance (console → Auth → your instance → Access) as a map keyed by the target instance — "datastore:<instance>" or "channel:<instance>". Each entry sets a level ceiling (read, write, or full) and, for datastore, rules keyed by namespace then collection.

How they are applied:

  • Default deny — once there are rules. An instance you don't list is unreachable, and once a collection has rules, any mode not listed for it is refused with 403. An entry with a level and no rules is the opposite: it gives every signed-in user that level over every namespace and collection in the instance. { "level": "full" } on its own means any user may read, write and delete any row — write the rules map, or keep that instance out of the entry.
  • Placeholders resolve from the token only. $auth.uid, $auth.identifier, $auth.email, $auth.claims.<key>, and $auth.profile.<key> are substituted from the verified token — never from the request body — so a client cannot claim to be someone else. But claims and profile are not equally trustworthy: $auth.claims.X is server- and admin-set, so it is safe to authorize on; $auth.profile.X is whatever the user typed at sign-up, so it must not drive an access decision (it is fine for display, or for stamping a name onto a document). See below.
  • $auth.email needs a verified address. A rule or channel pattern that names it is refused with 403 while the address is unverified — on a username instance the email is a sign-up field the user typed, and nobody checked it. Verification happens when a code sent to the address is used (sign-up verification, passwordless sign-in, or a password reset).

Profile vs. claims — the one rule that keeps you safe

A signed-in user's data lives in two separate bags, and the difference is a security boundary. Profile is what the user typed at sign-up — a display name, a team, anything your sign-up form collects. It is self-asserted: safe to show or stamp, but the user chose it, so it is never authoritative. Claims can only be set by you, from the admin console or the admin API; a user has no endpoint that writes a claim. That makes claims the only bag you may authorize on.

Profile versus claims — who sets each bag and what it may be used for
BagWho sets itRead in rules asUse it for
profileThe user, at sign-up (self-asserted)$auth.profile.<key>Display and stamping only — never access decisions.
claimsYou, via the console or admin API$auth.claims.<key>Authorization — a role or entitlement a user cannot grant themselves.

The rule of thumb — gate on claims, display profile. Sign-up writes profile, never claims, so a rule that trusts $auth.profile.role is satisfied by whatever a user typed on the form. If you want a role to mean something, set it as a claim from the console, never collect it on the sign-up form.

The four modes

Access rule modes and what each controls
ModeWhat you writeEffect
read"public", "authenticated", a filter list, or { "any": [ … ] }public and authenticated allow the whole collection; a filter list is ANDed into every query, so the user only ever sees rows that match; an any group is an OR — a row is visible if it matches any branch (see below).
createstamp (fields the server sets) and optional matchstamp writes values from the token into the new document, overriding anything the client sent — this is how authorship becomes forge-proof. match validates the resulting document.
updatematch, immutable, optional stampThe existing document must satisfy match, and the fields named in immutable may not change. A field match compares to an $auth.* value is immutable too — the owner of a row cannot give it to someone else — unless the update stamps it or the field has its own write gate under fields. A put replaces the whole document, so an update sends those fields unchanged.
deletematchThe existing document must satisfy match.
fieldsPer-field read and/or write, keyed by field nameA field's read masks it out of returned documents unless the caller matches; a field's write gates setting or changing it. Layered under the row rules (see below).

Filters use the same shape as a Datastore query filter — { "field", "op", "value" } with =, !=, <, <=, >, >=, in — except that value may be a placeholder. A placeholder that resolves to nothing is a hard denial, never an unconstrained match. Anywhere a filter list is accepted (read, match, a field's read/write) you may instead write { "any": [ [ … ], [ … ] ] } to mean OR: each inner list is an AND-group, and the row matches if it satisfies any group. Up to four groups.

A worked example

A small posts-and-comments board: anyone signed in can read everything, anyone can post, but you may only edit or delete your own posts. The client never sends an author field — the rules stamp it.

{
  "datastore:board": {
    "level": "full",
    "rules": {
      "_default": {
        "posts": {
          "read": "authenticated",
          "create": {
            "stamp": { "author_uid": "$auth.uid", "author_name": "$auth.profile.name" }
          },
          "update": {
            "match": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }],
            "immutable": ["author_uid"]
          },
          "delete": {
            "match": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }]
          }
        },
        "comments": {
          "read": "authenticated",
          "create": {
            "stamp": { "author_uid": "$auth.uid", "author_name": "$auth.profile.name" }
          },
          "delete": {
            "match": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }]
          }
        },
        "drafts": {
          "read": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }],
          "create": { "stamp": { "author_uid": "$auth.uid" } },
          "update": { "match": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }] },
          "delete": { "match": [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }] }
        }
      }
    }
  }
}

Read that top to bottom: posts and comments are readable by any signed-in user, while drafts uses a filter list so a query over drafts silently returns only the caller's own — no client-side filtering, and no way to page past it. comments lists no update, so editing a comment is refused. Any collection not named here is unreachable entirely.

Rules are scoped per namespace. The rules map is keyed first by namespace, then by collection — "_default" is the default (unnamed) namespace, which most apps use. A namespace that isn't listed is fully denied to end-user tokens, so a namespace is a real isolation boundary: one auth instance can front many datastore namespaces (say one per customer) and a user granted rules in customerA cannot reach customerB's rows by changing the namespace in the request — even for a collection they own in their own namespace. To bind a user to their own namespace automatically, name it from an authoritative claim (for example a tenant claim you set server-side) and give only that namespace rules.

level is a ceiling, not the permission. It gates which endpoints the token may call, before rules are consulted; the rules decide which rows it may touch. Deleting documents is gated at full, so an app whose users delete their own rows needs "level": "full" — as above. That is not a wider grant than write in practice: delete.match still confines every deletion to the caller's own rows. Saving a delete rule under a level below full (or a create/update rule under read) is rejected at save time, rather than surfacing at runtime as PERMISSION_DENIED — insufficient grant for datastore.

What an identity token cannot do

An access entry can name only datastore: and channel: instances — a search: key is rejected at save — so every other API refuses an identity token whatever the rules say. Within Datastore and Channel, these need an organization API key and answer 403 PERMISSION_DENIED to an identity token:

  • listing namespaces, and dropping one,
  • transactions,
  • queries with a join,
  • listing, creating and deleting indexes,
  • minting a publish-capable channel token.

OR conditions and field-level access

Two extensions cover rules a single AND of filters can't express: reading a row for more than one reason, and controlling access within a document.

OR — read (or match) a row for any of several reasons. Wrap groups in { "any": [ … ] }: each inner list is an AND, and the row matches if it satisfies any group. A forum post visible to its author or when it's public:

"posts": {
  "read": {
    "any": [
      [{ "field": "author_uid", "op": "=", "value": "$auth.uid" }],
      [{ "field": "visibility",  "op": "=", "value": "public"    }]
    ]
  }
}

A query over posts then returns the caller's own rows plus every public one, de-duplicated, in a single request — you don't run two queries and merge. OR is accepted anywhere a filter list is (a mode's read/match and a field's read/write), up to four groups. Each branch must be index-served just like an ordinary filtered query.

Field-level — read masking and write gates. Under a collection's row rules, fields narrows access field by field. A field's read removes it from returned documents unless the caller matches (per document); a field's write allows setting or changing it only when the target document matches. Here anyone signed in reads a profile, but email is visible only on the row you own, and handle may only be changed on your own row:

"profiles": {
  "read": "authenticated",
  "create": { "stamp": { "owner": "$auth.uid" } },
  "update": { "match": [{ "field": "owner", "op": "=", "value": "$auth.uid" }] },
  "fields": {
    "email":  { "read":  [{ "field": "owner", "op": "=", "value": "$auth.uid" }] },
    "handle": { "write": [{ "field": "owner", "op": "=", "value": "$auth.uid" }] }
  }
}

A rule constrains the DOCUMENT, not the caller. Every filter compares a field of the target row against a value from the token, so "only an admin may set this" is not expressible directly — there is no field of the caller to test. Stamp the deciding value onto the row at create time and compare that, or do the write through a function, which runs with the org's own access. And a rule that names a placeholder the caller does not have — $auth.claims.tier on a user with no tier claim — refuses the whole operation with 403, not just that field: a rule written for admins would block every ordinary user's write to the collection.

Masking is layered under the row-level read — it never widens access, only hides fields inside a row the caller could already read. Field names must be top-level (no dotted paths). A field with no rule is always visible and always writable, so masking and gating are opt-in.

A masked field is not queryable either. An end-user token may not name it in where, order, group, a join key or a metric — those are refused with 403. An organization API key is unaffected. For a true secret, prefer a separate collection over masking.

Channel access

A channel entry lists the channel patterns a user may subscribe to. Patterns can be templated with the same placeholders, so a per-user channel needs no server logic:

{
  "channel:board-live": {
    "level": "read",
    "channels": ["posts.*", "comments.*", "dm.$auth.uid"]
  }
}

An end-user token minting a channel token gets a subscribe-only token confined to those patterns, and its presence identity is forced to the user's own uid — a subscriber cannot spoof someone else on the roster. Publishing still requires an organization API key or a publish-capable token minted server-side.

The channel token expires no later than the identity token it was minted from, so expires_at can be earlier than the ttl_seconds you asked for — mint a new one after each refresh. Once the account is revoked, a new subscribe with that channel token gets 401; a socket already open stays open until the channel token expires.

End-to-end example

One browser file — no API key, no server of your own:

const API = "https://api.altengine.net";

// 1. Sign in against the auth instance.
const res = await fetch(API + "/v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/signin", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ identifier: "[email protected]", password: "…" })
});
const session = await res.json();
if (session.mfa_required) { /* prompt for the 2FA code, then POST /2fa/verify */ }

// 2. Use the id_token directly against Datastore.
const posts = await fetch(API + "/v1/datastore/board/ns/_default/col/posts/query", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + session.id_token,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    order: [{ "field": "__created__", "dir": "desc" }],
    limit: 25
  })
}).then((r) => r.json());

// 3. Write one. `author_uid` is stamped from the token — don't send it.
await fetch(API + "/v1/datastore/board/ns/_default/col/posts/documents", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + session.id_token,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ documents: [{ data: { title: "Hello", body: "First post" } }] })
});

// 4. When a data call returns 401, refresh once and retry.
const fresh = await fetch(API + "/v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/token/refresh", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ refresh_token: session.refresh_token })
}).then((r) => r.json());

The embeddable form

If you'd rather not build sign-in screens, every instance serves a ready-made one. Drop in one script tag and an element to mount into:

<div data-altengine-auth></div>
<script src="https://api.altengine.net/v1/auth/3f8a2c1e-9b74-4d2a-8f61-0c5d7e2a9b13/embed.js"></script>

The form renders itself from your instance's config — the right sign-up fields, the sign-in methods you've enabled, CAPTCHA when it's on — and handles sign-in, sign-up, passwordless codes, password reset, the 2FA code at sign-in, and passkey sign-in. Passkey registration is registerPasskey() below. It has no dependencies and no build step.

The form has no email-verification step and no 2FA enrolment. If Require email verification is on, build sign-up and sign-in yourself against the endpoints above; enrol 2FA from your own account page with /2fa/totp/start and /2fa/totp/confirm.

The form comes styled as a plain card that uses your page's font. Theme it with CSS variables, set on the element or any ancestor:

<div data-altengine-auth style="--ae-primary: #be123c; --ae-radius: 4px"></div>
Embeddable form theme variables
VariableDefaultUsed for
--ae-primary#2563ebSubmit button and focus ring
--ae-on-primary#fffSubmit button text
--ae-bg#fffCard and input background
--ae-text#1f2937Text and links
--ae-muted#4b5563Status messages
--ae-border#6b7280Input borders
--ae-line#e5e7ebCard border
--ae-error#b91c1cErrors and required markers
--ae-radius8pxCorner radius
--ae-fontyour page'sFont family
--ae-width24remMaximum width

Add the class ae-dark to the element or any ancestor for the dark palette. Your own rules on the form's classes override the built-in ones without !important.

To style it entirely yourself, add ae-unstyled (or pass { unstyled: true } to mount()) and no CSS is injected:

<div data-altengine-auth class="ae-unstyled"></div>

The markup is semantic and accessible (real <form>, <label> and <button> elements, required fields announced, errors in a live region, managed focus), with stable class hooks: .ae-auth, .ae-title, .ae-form, .ae-field, .ae-label, .ae-required, .ae-input, .ae-checkbox, .ae-submit, .ae-note, .ae-error, .ae-alt, .ae-alt-btn, .ae-toggle and .ae-captcha. A page whose Content-Security-Policy blocks inline styles gets the unstyled form.

The script keeps the session in localStorage and exposes a small API on window.altengineAuth: mount(el, opts), getSession(), refresh(), signOut(), registerPasskey(totpCode?), listPasskeys() and deletePasskey(id). The form shows the server's error message, and a failed refresh() or registerPasskey() rejects with an Error whose message is that text, with the error code (such as TOTP_REQUIRED) and HTTP status on it. It also fires an altengine:auth event on document whenever the session changes, so the rest of your page can react:

document.addEventListener("altengine:auth", (e) => {
  const session = e.detail.session;   // null when signed out
  if (session) loadPosts(session.id_token);
});

Settings

Per-instance auth settings
SettingWhat it does
Allow sign-upWhether POST /signup is open. Off means accounts are created from the console only.
Allowed originsBrowser origin allowlist (up to 20). Empty allows any origin.
CAPTCHACloudflare Turnstile on /signup, /signin, /passwordless/start, /password/reset/start and /email/verify/start. Turn it on, enter your Turnstile site key, and save your Turnstile secret key — nothing is checked until the secret is saved. Clients send the Turnstile token as captcha_token; a missing or rejected token returns 400 (CAPTCHA_REQUIRED / CAPTCHA_FAILED).
Passkeys / Passwordless / 2FAPer-method toggles. Off by default; a call to a disabled method's endpoints returns 403.
Identity token lifetime5 minutes to 24 hours; 1 hour by default.
Refresh token lifetime1 hour to 90 days; 30 days by default.
One-time code lifetime5 to 30 minutes; 10 by default. Also the minimum interval between code requests.
From nameThe display name on every auth email, and the issuer shown in authenticator apps. When empty, emails use the instance name and authenticator apps show altengine.
Rate limitLower the per-instance request budget below the default ceiling.

Cost

Auth bills on three things — accounts created, sign-ins, and how many accounts you keep. See the pricing page for exact rates.

How each Auth billing axis is metered
AxisHow it's metered
Sign-upsOne per account created, whether from POST /signup or the console. A one-time cost per user.
Sign-insOne per issued token pair — every successful sign-in and every token/refresh. Deliberately cheap, so a short identity-token lifetime costs almost nothing.
Stored usersYour account count, sampled hourly and averaged over the month — the same shape as stored data. Dormant accounts still count, so pruning accounts you'll never use again is the lever here.

Reading a token costs nothing: verifying an id_token on a Datastore or Channel request is not a metered auth event — that request bills on its own service's axes.

Limits

Auth API limits
LimitValue
Sign-up fields20
Password length8 – 256 characters
Request body64 KB; larger returns 413
Profile, claims4 KB each, as JSON
Allowed origins20
Identity token lifetime5 minutes – 24 hours (default 1 hour)
Refresh token lifetime1 hour – 90 days (default 30 days)
One-time code lifetime5 – 30 minutes (default 10)
Requests per client~1 per second, small burst
Failed sign-ins before lockout10 in a row, then 15 minutes
Guesses per one-time code5, then the code is dead
2FA challenge lifetime5 minutes, single use

The lockout is per account and counts every wrong secret together: passwords, authenticator codes, and emailed one-time codes. While it lasts, even the right password or code gets the same 401 a wrong one does, and the account is sent no new codes. The count resets only when a correct secret is accepted, so after the 15 minutes one more wrong secret locks the account again. A correct authenticator code is accepted once — re-entering it inside its window is refused.

Errors

Errors use standard HTTP status codes and the same JSON shape as the rest of the API. Beyond the common codes, Auth adds:

Auth-specific error codes
StatusCodeMeaning
400INVALID_ARGUMENTA missing or invalid field, a password outside 8–256 characters, or a passkey response that could not be verified.
400CAPTCHA_REQUIREDCAPTCHA is on and the request carried no captcha_token.
400CAPTCHA_FAILEDThe captcha_token was rejected.
400FAILED_PRECONDITION2FA confirm or disable without an enrolment, or a passkey state that is expired or already used — start the ceremony again.
401UNAUTHENTICATEDWrong credentials, an unknown or locked account, or an expired or revoked code, challenge, or token. Sign-in returns the same error either way, by design.
401REAUTH_REQUIREDThe id_token predates a password reset; sign in again before changing 2FA or passkeys.
401TOTP_REQUIREDAdding a passkey to an account with 2FA on needs totp_code.
403PERMISSION_DENIEDSign-up or the requested method is disabled, the account is disabled, or an access rule refused the operation.
403EMAIL_UNVERIFIEDThe password was right, but email verification is required and the address is unconfirmed.
403ORIGIN_NOT_ALLOWEDThe browser Origin is not on the instance's allowlist.
404NOT_FOUNDThe instance, the user (/me), or the passkey does not exist.
409ALREADY_EXISTSSign-up with an identity value that is already registered.
409FAILED_PRECONDITION2FA is already enabled and must be disabled (with a current code) before re-enrolling.
413INVALID_ARGUMENTThe request body is over 64 KB.
429RATE_LIMITEDToo many requests from this client or to this instance. Back off and retry.
503CAPTCHA_UNAVAILABLEThe CAPTCHA check could not be reached. Retry.