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: /v1/auth/{instance}/…. The instance name is public — it is meant to ship in your client code, the way a base URL does.
Unlike the other services, the auth endpoints are public: they take no organization API key, because the end user's own credentials are the trust boundary. 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 — enough for a real sign-in form, not enough for credential stuffing. 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}/config | Public | Client-safe sign-in config: the sign-up fields, which methods are on, the CAPTCHA site key. |
GET /v1/auth/{instance}/embed.js | Public | The drop-in embeddable auth form. |
POST /v1/auth/{instance}/signup | Public | Create an account and sign in. |
POST /v1/auth/{instance}/signin | Public | Sign in with the identity field and a password. |
POST /v1/auth/{instance}/token/refresh | Refresh token | Exchange a refresh token for a fresh id_token (rotates the refresh token). |
POST /v1/auth/{instance}/signout | Refresh token | Revoke a refresh token. |
GET /v1/auth/{instance}/me | id_token | The signed-in user's profile and claims. |
POST /v1/auth/{instance}/passwordless/start | Public | Email a one-time sign-in code. |
POST /v1/auth/{instance}/passwordless/verify | Public | Exchange that code for tokens. |
POST /v1/auth/{instance}/password/reset/start | Public | Email a one-time password-reset code. |
POST /v1/auth/{instance}/password/reset/verify | Public | Set a new password with that code. |
POST /v1/auth/{instance}/2fa/totp/start | id_token | Begin authenticator-app enrolment; returns the secret and an otpauth URI for a QR code. |
POST /v1/auth/{instance}/2fa/totp/confirm | id_token | Confirm enrolment with a current code — 2FA becomes active. |
POST /v1/auth/{instance}/2fa/totp/disable | id_token | Turn 2FA off; requires a current code. |
POST /v1/auth/{instance}/2fa/verify | MFA challenge | Complete a 2FA sign-in and receive tokens. |
POST /v1/auth/{instance}/passkey/register/start | id_token | Begin adding a passkey to the signed-in account. |
POST /v1/auth/{instance}/passkey/register/finish | id_token | Finish adding the passkey. |
POST /v1/auth/{instance}/passkey/login/start | Public | Begin passkey sign-in; returns the browser's challenge options. |
POST /v1/auth/{instance}/passkey/login/finish | Public | Finish passkey sign-in and receive tokens. |
GET /v1/auth/{instance}/passkeys | id_token | List the signed-in user's passkeys. |
DELETE /v1/auth/{instance}/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. That is the whole difference between an email-and-password app and a username-and-password app — no code change, just config (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.
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/app/config
# → {
# "instance": "app",
# "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 at least 8 characters, or sign in with the identity value and the password.
POST /v1/auth/app/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/app/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/app/passwordless/start
{ "identifier": "[email protected]" }
# → { "ok": true } always 200 — never reveals whether the account exists
POST /v1/auth/app/passwordless/verify
{ "identifier": "[email protected]", "code": "418207" }
# → { "id_token": "…", "refresh_token": "…", "user": { … } }Password reset works exactly the same way, at /password/reset/start and /password/reset/verify (the latter also takes new_password). Setting a new password revokes every existing refresh token, so other sessions are signed out.
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, so a stolen session can't quietly disable it.
Once 2FA is active, sign-in becomes two steps. /signin (and passwordless verify) return a challenge instead of tokens:
POST /v1/auth/app/signin
{ "identifier": "[email protected]", "password": "…" }
# → { "mfa_required": true, "mfa_token": "…" }
POST /v1/auth/app/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 and there is no password to phish. 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. A successful passkey assertion is strong single-step authentication, so it issues tokens directly with no separate 2FA step. Users can list and remove their passkeys at GET /passkeys and DELETE /passkeys/{credId}. The embeddable form handles all of the encoding for you.
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. |
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). Short identity tokens are the point: they are what a browser carries around, and a short life bounds the damage if one leaks.
POST /v1/auth/app/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 a user who has been disabled, or whose claims changed, is reflected on the next refresh rather than at the end of the token's life. The practical pattern is to catch a 401 from a data request, refresh once, and retry. POST /signout revokes a refresh token.
Access rules
Access rules are what make a backend-less app safe. They 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.
Two properties make this trustworthy:
- Default deny. An instance you don't list is unreachable. Once a collection has
rules, any mode not listed for it is refused with403. - 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.
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. |
Why this matters. Sign-up fields used to land in claims. Because a user picks their own sign-up values, a rule that trusted $auth.claims.role could be satisfied by a value the user simply chose — an account could hand itself "role": "admin" at sign-up and walk through the door. Sign-up now writes profile instead, so authorizing on $auth.claims.X is safe: there is no request a user can make that sets a claim. The rule of thumb — gate on claims, display profile. 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. |
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. To stop this misconfiguration reaching production, saving a delete rule under a level below full (or a create/update rule under read) is rejected up front — the console won't store a rule the level can't reach, so you fix it once at save time instead of debugging a PERMISSION_DENIED — insufficient grant for datastore — at runtime.
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 only an admin may flip featured:
"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" }] },
"featured": { "write": [{ "field": "tier", "op": "=", "value": "$auth.claims.tier" }] }
}
}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.
Masking is an output projection, not secrecy. A masked field is removed from the document body, but its value is still queryable — a client can filter, sort, or aggregate on it and infer the value through range queries. Use field read to keep a field out of a shared view, not to hide a secret from someone who can query the collection. For a true secret, keep it in a separate collection the caller can't read at all.
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.
End-to-end example
The whole point, in 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/app/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/app/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/app/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, 2FA, and the passkey ceremonies. It has no dependencies and no build step.
It ships no CSS. The markup is semantic and accessible (real <form>, <label> and <button> elements, required and invalid states announced, errors in a live region, managed focus), with stable ae- class hooks — .ae-auth, .ae-form, .ae-field, .ae-label, .ae-input, .ae-submit, .ae-error, .ae-note, .ae-alt and friends — so your own stylesheet themes it and it never fights your design.
The script keeps the session in localStorage and exposes a small API on window.altengineAuth: mount(el, opts), getSession(), refresh(), signOut(), registerPasskey(), listPasskeys() and deletePasskey(id). 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 | Bot protection on sign-up and sign-in. Turn it on and supply your site key; clients then send a captcha_token. |
| 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 sign-in-code and password-reset emails, and the issuer shown in authenticator apps. |
| 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 |
| Minimum password length | 8 characters |
| 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 |
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 |
|---|---|---|
| 401 | UNAUTHENTICATED | Wrong credentials, an unknown account, or an expired code, challenge, or token. Sign-in returns the same error either way, by design. |
| 403 | PERMISSION_DENIED | Sign-up or the requested method is disabled, the account is disabled, or an access rule refused the operation. |
| 409 | FAILED_PRECONDITION | 2FA is already enabled and must be disabled (with a current code) before re-enrolling. |
| 429 | RATE_LIMITED | Too many requests from this client or to this instance. Back off and retry. |