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
- 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.
- A client signs up or signs in and receives an
id_tokenplus arefresh_token. - 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
| Method & path | Auth | Purpose |
|---|---|---|
GET /v1/auth/{instance-id}/config | Public | Client-safe sign-in config: the sign-up fields, which methods are on, the CAPTCHA site key. |
GET /v1/auth/{instance-id}/embed.js | Public | The drop-in embeddable auth form. |
POST /v1/auth/{instance-id}/signup | Public | Create an account and sign in. |
POST /v1/auth/{instance-id}/signin | Public | Sign in with the identity field and a password. |
POST /v1/auth/{instance-id}/token/refresh | Refresh token | Exchange a refresh token for a fresh id_token (rotates the refresh token). |
POST /v1/auth/{instance-id}/signout | Refresh token | Revoke a refresh token. |
GET /v1/auth/{instance-id}/me | id_token | The signed-in user's profile and claims. |
POST /v1/auth/{instance-id}/passwordless/start | Public | Email a one-time sign-in code. |
POST /v1/auth/{instance-id}/passwordless/verify | Public | Exchange that code for tokens. |
POST /v1/auth/{instance-id}/password/reset/start | Public | Email a one-time password-reset code. |
POST /v1/auth/{instance-id}/password/reset/verify | Public | Set a new password with that code. |
POST /v1/auth/{instance-id}/email/verify/start | Public | Email a code to verify an address (when verification is required). |
POST /v1/auth/{instance-id}/email/verify/confirm | Public | Confirm the code, verify the address, and receive tokens. |
POST /v1/auth/{instance-id}/2fa/totp/start | id_token | Begin authenticator-app enrolment; returns the secret and an otpauth URI for a QR code. |
POST /v1/auth/{instance-id}/2fa/totp/confirm | id_token | Confirm enrolment with a current code — 2FA becomes active. |
POST /v1/auth/{instance-id}/2fa/totp/disable | id_token | Turn 2FA off; requires a current code. |
POST /v1/auth/{instance-id}/2fa/verify | MFA challenge | Complete a 2FA sign-in and receive tokens. |
POST /v1/auth/{instance-id}/passkey/register/start | id_token | Begin adding a passkey to the signed-in account. Takes totp_code when the account has 2FA on. |
POST /v1/auth/{instance-id}/passkey/register/finish | id_token | Finish adding the passkey. |
POST /v1/auth/{instance-id}/passkey/login/start | Public | Begin passkey sign-in; returns the browser's challenge options. |
POST /v1/auth/{instance-id}/passkey/login/finish | Public | Finish passkey sign-in and receive tokens. |
GET /v1/auth/{instance-id}/passkeys | id_token | List the signed-in user's passkeys. |
DELETE /v1/auth/{instance-id}/passkeys/{credId} | id_token | Remove 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.
| What | Rule |
|---|---|
Field key | Starts 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 label | Optional, up to 60 characters. |
email value | Trimmed 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 value | Trimmed; up to 512 characters. |
number value | A number, or a string that parses as one. |
boolean value | true or "true"; anything else is stored as false. |
| Whole profile | Up 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 /signupreturns{ "verification_required": true, "user": … }with noid_token, and emails a six-digit code. - Sign-in is gated. Even with the correct password,
/signinreturns403with codeEMAIL_UNVERIFIEDuntil the address is confirmed. (A wrong password still returns the same generic401as always, so the gate never reveals whether an account exists.) - Confirm to finish.
/email/verify/confirmconsumes the code, marks the address verified, and returns tokens — the user is signed in./email/verify/startsends a new code (always200, 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
emailfield — 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.
| Setting | Rules | When empty |
|---|---|---|
| From name | Up 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-part | Up 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:
| Field | Meaning |
|---|---|
id_token | The 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_token | Used only against this auth instance, to get a new id_token. Long-lived. |
expires_at | Unix seconds when the id_token expires. |
refresh_expires_at | Unix seconds when the refresh token expires. |
user | uid, 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.setDisabledin a function), - deleting it,
- a completed password reset,
env.auth.revokeSessionsin 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 with403. An entry with aleveland norulesis 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 therulesmap, 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. Butclaimsandprofileare not equally trustworthy:$auth.claims.Xis server- and admin-set, so it is safe to authorize on;$auth.profile.Xis 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.emailneeds a verified address. A rule or channel pattern that names it is refused with403while 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.
| Bag | Who sets it | Read in rules as | Use it for |
|---|---|---|---|
profile | The user, at sign-up (self-asserted) | $auth.profile.<key> | Display and stamping only — never access decisions. |
claims | You, 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
| Mode | What you write | Effect |
|---|---|---|
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). |
create | stamp (fields the server sets) and optional match | stamp 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. |
update | match, immutable, optional stamp | The 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. |
delete | match | The existing document must satisfy match. |
fields | Per-field read and/or write, keyed by field name | A 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>| Variable | Default | Used for |
|---|---|---|
--ae-primary | #2563eb | Submit button and focus ring |
--ae-on-primary | #fff | Submit button text |
--ae-bg | #fff | Card and input background |
--ae-text | #1f2937 | Text and links |
--ae-muted | #4b5563 | Status messages |
--ae-border | #6b7280 | Input borders |
--ae-line | #e5e7eb | Card border |
--ae-error | #b91c1c | Errors and required markers |
--ae-radius | 8px | Corner radius |
--ae-font | your page's | Font family |
--ae-width | 24rem | Maximum 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
| Setting | What it does |
|---|---|
| Allow sign-up | Whether POST /signup is open. Off means accounts are created from the console only. |
| Allowed origins | Browser origin allowlist (up to 20). Empty allows any origin. |
| CAPTCHA | Cloudflare 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 / 2FA | Per-method toggles. Off by default; a call to a disabled method's endpoints returns 403. |
| Identity token lifetime | 5 minutes to 24 hours; 1 hour by default. |
| Refresh token lifetime | 1 hour to 90 days; 30 days by default. |
| One-time code lifetime | 5 to 30 minutes; 10 by default. Also the minimum interval between code requests. |
| From name | The 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 limit | Lower 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.
| Axis | How it's metered |
|---|---|
| Sign-ups | One per account created, whether from POST /signup or the console. A one-time cost per user. |
| Sign-ins | One per issued token pair — every successful sign-in and every token/refresh. Deliberately cheap, so a short identity-token lifetime costs almost nothing. |
| Stored users | Your 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
| Limit | Value |
|---|---|
| Sign-up fields | 20 |
| Password length | 8 – 256 characters |
| Request body | 64 KB; larger returns 413 |
| Profile, claims | 4 KB each, as JSON |
| Allowed origins | 20 |
| Identity token lifetime | 5 minutes – 24 hours (default 1 hour) |
| Refresh token lifetime | 1 hour – 90 days (default 30 days) |
| One-time code lifetime | 5 – 30 minutes (default 10) |
| Requests per client | ~1 per second, small burst |
| Failed sign-ins before lockout | 10 in a row, then 15 minutes |
| Guesses per one-time code | 5, then the code is dead |
| 2FA challenge lifetime | 5 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:
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_ARGUMENT | A missing or invalid field, a password outside 8–256 characters, or a passkey response that could not be verified. |
| 400 | CAPTCHA_REQUIRED | CAPTCHA is on and the request carried no captcha_token. |
| 400 | CAPTCHA_FAILED | The captcha_token was rejected. |
| 400 | FAILED_PRECONDITION | 2FA confirm or disable without an enrolment, or a passkey state that is expired or already used — start the ceremony again. |
| 401 | UNAUTHENTICATED | Wrong 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. |
| 401 | REAUTH_REQUIRED | The id_token predates a password reset; sign in again before changing 2FA or passkeys. |
| 401 | TOTP_REQUIRED | Adding a passkey to an account with 2FA on needs totp_code. |
| 403 | PERMISSION_DENIED | Sign-up or the requested method is disabled, the account is disabled, or an access rule refused the operation. |
| 403 | EMAIL_UNVERIFIED | The password was right, but email verification is required and the address is unconfirmed. |
| 403 | ORIGIN_NOT_ALLOWED | The browser Origin is not on the instance's allowlist. |
| 404 | NOT_FOUND | The instance, the user (/me), or the passkey does not exist. |
| 409 | ALREADY_EXISTS | Sign-up with an identity value that is already registered. |
| 409 | FAILED_PRECONDITION | 2FA is already enabled and must be disabled (with a current code) before re-enrolling. |
| 413 | INVALID_ARGUMENT | The request body is over 64 KB. |
| 429 | RATE_LIMITED | Too many requests from this client or to this instance. Back off and retry. |
| 503 | CAPTCHA_UNAVAILABLE | The CAPTCHA check could not be reached. Retry. |