<!-- section: Auth API · status: building · source: docs/auth-api/01-index.md -->

> Sign-up, sign-in, sessions and JWTs for your app's users, with your users in a database of their own.


# Auth

Your users, their passwords, their sessions and the tokens your API verifies —
without you running any of it.

```bash Signing someone up
curl -X POST https://<your-deployment>/v1/auth/sign-up/email \
  -H "Authorization: Bearer pk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"…","name":"Ada Lovelace"}'
```

```json The response
{
  "token": "onSnRF8wZMuCs03Li7lcKJT2tLTvHjQO",
  "user": {
    "id": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M",
    "email": "ada@example.com",
    "emailVerified": false,
    "name": "Ada Lovelace"
  }
}
```

A `Set-Cookie` comes back with it. That cookie *is* the session — httpOnly, Secure,
SameSite=Lax, and namespaced to your project so a browser can hold sessions for
several apps at once without them colliding.

## Your users are in their own database

Every project gets its own Postgres database. Not a schema, not a shared table with
a project column — a separate database, in a region you choose.

This is the part worth understanding, because it is what you are buying. Isolation
by convention means every query in our codebase has to remember a `where` clause,
and one that forgets is a breach. Isolation by database means two customers' rows
are never in the same query scope, so there is no clause to forget.

What follows from it:

- **Your data is yours to point at.** One project, one database, one connection.
- **Nobody else's traffic is your problem.** Separate compute per project.
- **Point-in-time recovery is per project**, not per platform.

The cost is honest and worth naming: a database that has been idle scales to zero,
so the first sign-in after a quiet period pays a wake-up of a second or two. Steady
traffic never sees it.

## Both key kinds work here

Auth is the one API you can call from a browser, because signing in happens in one
and there is nowhere else for it to happen.

| Key | Use it | Origin check |
|---|---|---|
| `pk_…` publishable | In your app's frontend | Enforced — the request must come from an origin you registered |
| `sk_…` secret | On your server | Not applicable — a server sends no `Origin`, and the key is the authentication |

A publishable key in a browser is safe *because* of the origin check. Register your
app's origins in the console; exact matches only, no wildcards. A project with no
registered origins accepts no browser traffic at all, which is the right default for
one whose owner has not named any.

Cross-origin calls from a registered origin allow credentials, so send
`credentials: "include"` from `fetch` or the session cookie will not stick.

## Verifying tokens without calling us

`GET /v1/auth/token` returns a short-lived JWT. Your API verifies it locally against
our JWKS, so we are not a network hop on every request you serve.

```json The claims
{
  "sub": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M",
  "sid": "h0bsPL9wBPaRfvslAcU2q0w8VPeF4wJu",
  "org_id": null,
  "role": null,
  "email": "ada@example.com",
  "email_verified": false,
  "iss": "https://<your-deployment>",
  "exp": 1786977488
}
```

Signed with **EdDSA/Ed25519**, fifteen minute expiry, keys at
`GET /v1/auth/jwks`. The signing keys live in *your* project's database, so your
tokens are signed with a keypair nobody else holds — a token minted for another
project cannot verify against yours.

**`org_id` and `role` are in the payload from today, before organizations ship.**
They are null until then. This is deliberate: writing your API against these claims
now means it keeps working when teams arrive, and retrofitting them after you have
built a flat user model is the part that hurts.

## Settings

Some of how auth behaves is your decision, not ours. Read and change it with a secret
key:

```bash
curl https://<your-deployment>/v1/auth/settings -H "Authorization: Bearer sk_live_…"
```

```json
{ "requireEmailVerification": false, "disableSignUp": false, "minPasswordLength": 8 }
```

| Setting | Default | What it does |
|---|---|---|
| `requireEmailVerification` | `false` | Refuse a session until the address is confirmed |
| `disableSignUp` | `false` | Stop new account creation; existing users still sign in |
| `minPasswordLength` | `8` | The shortest password you accept, from 8 to 64 |

`PATCH` changes only what you send, so one setting can move without restating the rest:

```bash
curl -X PATCH https://<your-deployment>/v1/auth/settings \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"disableSignUp":true}'
```

**Changes take effect on the next request**, not after a cache expires.

A misspelled setting is **refused**, not ignored — a setting that reads as saved and
does nothing is worse than an error:

```json
{ "error": "Unknown setting: requireEmailVerifcation. Known settings are requireEmailVerification, disableSignUp, minPasswordLength." }
```

**Turning on `requireEmailVerification` affects users you already have.** Anyone who
never confirmed their address stops being able to sign in until they do. That is the
point of the setting, and worth knowing before you flip it to see what it does.

`disableSignUp` is not a suspension. Sign-in, sessions and tokens keep working; only
account creation stops — for an app that provisions users elsewhere, or one you are
closing to new signups.

## Email

Three flows send mail, and all of them go out under **your project's name** rather than
ours — in the subject line and in the From column, which is the half people actually
read:

```
From: "Acme" <noreply@xplatform.dev>
Subject: Confirm your email for Acme
```

The sending address stays ours, because it is the domain verified with our mail
provider. Only the name is yours. Someone signing up for your app has never heard of us,
and their inbox should not be where they find out.

| Flow | Trigger | What the person gets |
|---|---|---|
| Confirm your email | Automatic on sign-up | A link that marks the address verified |
| Reset your password | `POST /v1/auth/request-password-reset` | A single-use link, valid for an hour |
| Confirm account deletion | `POST /v1/auth/delete-user` with no password | A link that deletes the account |

**Verification does not block signing in.** A new user gets the confirmation mail and can
use your app straight away; `emailVerified` on the user record tells you whether they
have confirmed, and what you do about it is your product's decision, not ours. If you
want the harder rule — no session until confirmed — say so and we will make it a
per-project setting.

**The reset flow is enumeration-resistant.** An address that does not exist gets the same
`200` and the same message as one that does:

```json
{ "status": true, "message": "If this email exists in our system, check your email for the reset link" }
```

**`redirectTo` must be an origin you registered.** A reset link that could land anywhere
is an open redirect with a valid session attached, so an unregistered origin is refused
with `INVALID_REDIRECT_URL`. Add your app's origins in the console.

That last flow is worth knowing about if you use social login: a user with no password
and a session older than a day cannot confirm a deletion by password, because they have
none. Calling `delete-user` without one sends the confirmation mail instead, so they can
still leave.

## Social login

Google and GitHub, **with your own OAuth app**.

That last part is the design, not a limitation. The consent screen names the application
asking for access, so if we ran one app for the whole platform your users would be told
that *xplatform* wanted their email — from a company they have never heard of, at the
exact moment people abandon a sign-up. Your app, your name on the screen.

**1. Register this redirect URL** with the provider:

```
https://<your-deployment>/v1/auth/callback/google
```

It points at us, because the token exchange happens here. Getting this wrong is the most
common way an OAuth setup fails, and it fails on the provider's site where we can't tell
you anything useful — so the console shows the exact string to paste.

**2. Give us the credentials**, from your server:

```bash
curl -X PUT https://<your-deployment>/v1/auth/providers/google \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"clientId":"…","clientSecret":"…"}'
```

**3. Send people to it:**

```bash
curl -X POST https://<your-deployment>/v1/auth/sign-in/social \
  -H "Authorization: Bearer pk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"provider":"google","callbackURL":"https://yourapp.com/welcome"}'
```

```json
{ "url": "https://accounts.google.com/o/oauth2/v2/auth?..." }
```

Redirect the browser there. They come back signed in, with the same session cookie a
password sign-in produces.

### What you can read back

`GET /v1/auth/providers` lists what's configured:

```json
[
  {
    "provider": "google",
    "clientId": "123.apps.googleusercontent.com",
    "callbackUrl": "https://<your-deployment>/v1/auth/callback/google"
  }
]
```

**Client secrets are never returned.** They're encrypted at rest and there is no endpoint
that reads one back — you can replace a secret, not retrieve it. `PUT` replaces the pair
whole; there's no partial update, because a rotated secret and a swapped app look
identical from our side and a half-update would let you believe you'd changed something
you hadn't.

`DELETE /v1/auth/providers/google` disconnects it. **Accounts already linked through it
are left alone** — someone who signed up with Google keeps their user record and
everything attached to it. Disconnecting stops new sign-ins; it doesn't delete people.

A provider you haven't configured answers `404 PROVIDER_NOT_FOUND` rather than showing
your users a button that fails after they've left your site.

## Two-factor

TOTP and recovery codes. No SMS — it costs money per message, is the weakest common
second factor, and its main appeal is familiarity rather than security.

```bash
curl -X POST https://<your-deployment>/v1/auth/two-factor/enable \
  -H "Authorization: Bearer pk_live_…" -b "session cookie" \
  -H "Content-Type: application/json" -d '{"password":"…"}'
```

Back comes a `totpURI` for a QR code and ten single-use backup codes. **The issuer in the
authenticator app is your project's name**, so somebody adding a code for their Acme
account sees Acme.

Once it's verified, a password sign-in stops returning a session and returns this
instead:

```json
{ "twoFactorRedirect": true, "twoFactorMethods": ["totp"] }
```

Send them to your second-factor screen, then `POST /v1/auth/two-factor/verify-totp` with
the six digits. That call returns the session.

**The TOTP secret and the backup codes are encrypted at rest**, under your project's own
signing secret — a key no other project holds.

## Passkeys

Sign in with a fingerprint, a face, or a security key. No password to phish.

**Passkeys are bound to your domain, not ours**, and that shapes the setup. A browser
only accepts a credential whose relying party is your own site, so we derive it from the
first browser origin you register:

```json
{ "rp": { "name": "Acme", "id": "app.acme.com" } }
```

The consequence to know before you build on it: **a passkey works on the domain it was
created for and nowhere else.** A project serving two unrelated domains can have passkeys
on one of them. That's WebAuthn's rule rather than ours, and there is no way around it —
the browser enforces it, not our server.

A project with no registered origins can't use passkeys at all, which is already true of
every other browser flow.

The flow is the standard WebAuthn one, and the client SDK does the hard part:

```
GET  /v1/auth/passkey/generate-register-options  → options with your rp
POST /v1/auth/passkey/verify-registration        → stores the credential
POST /v1/auth/sign-in/passkey                    → a session
```

## Organizations

Workspaces inside *your* app — a company, a team, a tenant of yours. Not to be confused
with your own xplatform organization; these live in your project's database and we never
see them.

```bash
curl -X POST https://<your-deployment>/v1/auth/organization/create \
  -H "Authorization: Bearer pk_live_…" -b "session cookie" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Engineering","slug":"acme-eng"}'
```

Whoever creates one is its `owner`. The other roles are `admin` and `member`:

| | organization | members | invitations |
|---|---|---|---|
| `owner` | update, delete | add, update, remove | send, cancel |
| `admin` | update | add, update, remove | send, cancel |
| `member` | read only | read only | read only |

A `member` attempting any of it gets a **403**, which we checked rather than assumed.

### It finally fills in `org_id`

The JWT has carried `org_id` and `role` since the first day this service existed, and
until now both were always null. That was deliberate — a consumer written against those
claims last month keeps working today without touching their code, which is the thing
that bites teams who add the claim afterwards.

```json
{ "sub": "Nrzmwy…", "org_id": "2kbhx7…", "role": "owner", "sid": "…" }
```

`org_id` is the **active** organization on the session, because one person can belong to
several and switch without their identity changing. Set it with
`POST /v1/auth/organization/set-active`; creating an organization makes it active
automatically.

`role` is read from the membership at the moment the token is minted, not copied onto
the session at sign-in — so demoting an admin takes effect on their next token rather
than their next sign-in.

### Invitations

`POST /v1/auth/organization/invite-member` emails the person a link. **The page it lands
on is yours** — we deliver the mail, your app renders the accept screen and calls
`accept-invitation`. Point it somewhere with the `invitationUrl` setting; the invitation
id is appended:

```
invitationUrl = https://app.acme.com/join   →   https://app.acme.com/join/<id>
```

Unset, it guesses at `/accept-invitation` on your first registered origin, so invitations
work before you have configured anything.

**An invitation whose email fails still returns 200.** The invitation row is written
before the mail is attempted and the send happens after, so a bounced or refused message
does not roll it back. `GET /v1/auth/organization/list-invitations` gives you the id, and
the link is just your URL plus that id — so a re-send is always possible without creating
a second invitation.

## Enterprise SSO

Your enterprise customers sign in with their own Okta, Entra or any OIDC or SAML 2.0
identity provider — and set it up themselves, without emailing anyone a certificate.

The shape is three deep, so it is worth naming: we host the service, you run an
application on it, and *your* customer is a company with a directory. An SSO provider
belongs to one of **your** organizations.

**1. Declare the identity provider's hosts.** Registering one makes our server fetch the
provider's discovery document, so the hosts have to be trusted in advance — otherwise
`sso/register` would be "make your server request any URL I name":

```bash
curl -X PATCH https://<your-deployment>/v1/auth/settings \
  -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
  -d '{"ssoOrigins":["https://bigcorp.okta.com"]}'
```

**Every origin the discovery document resolves to must be listed, not just the issuer.**
Providers routinely put their token and JWKS endpoints on other hosts — Google's issuer
is `accounts.google.com` while its token endpoint is `oauth2.googleapis.com`, and
registration fails with `discovery_untrusted_origin` naming the exact URL it refused.

These origins are trusted for SSO endpoints only. They never gain the right to make
browser requests against your API.

**2. An organization owner or admin registers the provider:**

```bash
curl -X POST https://<your-deployment>/v1/auth/sso/register \
  -H "Authorization: Bearer pk_live_…" -b "session cookie" \
  -H "Content-Type: application/json" -d '{
    "providerId": "bigcorp-okta",
    "issuer": "https://bigcorp.okta.com",
    "domain": "bigcorp.com",
    "organizationId": "org_…",
    "oidcConfig": { "clientId": "…", "clientSecret": "…",
      "discoveryEndpoint": "https://bigcorp.okta.com/.well-known/openid-configuration" }
  }'
```

A plain `member` gets **403** — registering an identity provider decides who can enter
the organization, so it takes an owner or an admin.

**3. Users sign in by email domain:**

```bash
curl -X POST https://<your-deployment>/v1/auth/sign-in/sso \
  -H "Authorization: Bearer pk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"email":"someone@bigcorp.com","callbackURL":"https://yourapp.com/"}'
```

Back comes a `url` to redirect to. Anyone arriving through that provider **joins the
linked organization automatically**, as a `member` — the point of enterprise SSO is that
staff arrive already belonging somewhere.

### SAML

Same registration call with `samlConfig` instead — the certificate and entry point come
from the IdP, so nothing is fetched and no origins need declaring.

Give the IdP administrator your service provider metadata:

```
GET /v1/auth/sso/saml2/sp/metadata?providerId=bigcorp-saml
```

It carries the ACS URL they need, which is ours:

```
http://<your-deployment>/v1/auth/sso/saml2/sp/acs/bigcorp-saml
```

IdP-initiated sign-in works without extra setup — the callback handles both the POST from
the IdP and the browser's follow-up GET.

## Directory sync (SCIM)

SSO answers "can this person sign in". SCIM answers "who exists at all" — a company that
hires someone on Monday expects them to have an account before they first open your app,
and expects it gone the day they leave. Neither works if people only appear when they
sign in.

**1. Issue the directory a credential.** From your server, naming the organization and
the administrator doing it:

```bash
curl -X POST https://<your-deployment>/v1/auth/scim-connections \
  -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
  -d '{"organizationId":"org_…","actorId":"user_…"}'
```

```json
{
  "token": "ba_scim_credential_…",
  "scimBaseUrl": "https://<your-deployment>/v1/scim/<projectId>/v2"
}
```

The token is returned **once** and stored only as a keyed digest — it can be rotated,
never read back. `actorId` must be an `owner` or `admin` of that organization; a plain
member gets **403**, because a directory credential can create and deactivate anybody in
it.

**2. Give your enterprise customer's IT team the base URL and token.** They paste both
into Okta, Entra or whatever they run. Authentication is `Authorization: Bearer <token>`.

Note the base URL carries your project id rather than authenticating by API key. SCIM
reserves the `Authorization` header for the directory's own token — a directory can't
send two — so the project moves into the path. It isn't a secret; the token is what
authorizes anything.

**3. That's it.** The directory drives the rest:

| | |
|---|---|
| `POST /Users` | Someone joins — a real user account, before they ever sign in |
| `PATCH /Users/:id` | `active: false` when they leave, which ends their sessions |
| `DELETE /Users/:id` | Removes the directory's resource |
| `POST /Groups` | Groups and memberships |
| `GET /Users?filter=…` | Equality filters on `userName`, `externalId`, `emails.value` |

**Deleting a SCIM resource does not delete the person.** Their account, their data and
anything you attached to it survive; the directory is disclaiming management, not issuing
an erasure. Use the delete-user API for that.

**Provisioning creates no way to sign in.** A provisioned person still needs SSO, a
password or a passkey — SCIM says who exists, not how they authenticate. Pair it with an
SSO provider on the same organization and the two halves meet.

Discovery endpoints (`/ServiceProviderConfig`, `/Schemas`, `/ResourceTypes`) are public,
so a directory can inspect what's supported before you hand it a token.

### Letting groups grant roles

By default a directory group is synced and grants nothing — a group is a fact about
someone else's directory until you say what it should mean in your product. Say it with
an explicit map:

```bash
curl -X PATCH https://<your-deployment>/v1/auth/settings \
  -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
  -d '{"scimRoleMap":{"directory-group-admins":"admin"}}'
```

The key is the group's `externalId`, falling back to its display name. Prefer the
`externalId`: a directory administrator can rename a group, and if a bare name granted
access then a rename would be a privilege change.

**Only `admin` and `member` can be granted this way.** `owner` is refused — the owner can
delete the organization and remove everyone in it, and that is not a decision to hand to
whoever administers an identity provider.

Two things this deliberately will not do:

**It never touches an owner.** However the groups change, an owner stays an owner. A
directory cannot lock a customer out of their own organization.

**Losing a group demotes, it does not remove.** Someone dropped from every mapped group
becomes a `member` rather than losing their membership, because an administrator moving
people between groups should not delete their access to their work.

Within those rules the directory owns the role: promote a synced user by hand and the next
sync will put them back where the directory says they belong. If you change the map itself,
existing users are reconciled on the directory's next request for them.

## Removing users

Two ways, because they are two different actions.

**The person deletes their own account.** Signed in, with their password:

```bash
curl -X POST https://<your-deployment>/v1/auth/delete-user \
  -H "Authorization: Bearer pk_live_…" \
  -H "Content-Type: application/json" \
  -b "session cookie" \
  -d '{"password":"…"}'
```

A password or a session less than a day old is required — this is irreversible, and a
stale cookie on a shared laptop should not be enough. Users who signed in with a social
provider have no password, so they need to sign in again first.

**You delete somebody's account.** From your server, with a secret key — for a support
request, an erasure mail, or clearing out a migration:

```bash
curl -X DELETE https://<your-deployment>/v1/auth/users/<user-id> \
  -H "Authorization: Bearer sk_live_…"
```

```json
{ "id": "zafSejoiTBShUIhggLMox0Tyz4qKrSrV", "email": "alan@example.com", "deleted": true }
```

Both are **hard deletes**. The account goes, and its sessions and linked providers go
with it. There is no undo and no recycle bin — if you need one, keep your own record
before calling.

`GET /v1/auth/users` lists them so you can decide what to remove:

```json
{
  "users": [
    {
      "id": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M",
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "emailVerified": false,
      "createdAt": "2026-08-17T14:22:39.655Z",
      "lastSeenAt": "2026-08-17T14:25:41.491Z"
    }
  ],
  "nextCursor": null
}
```

`lastSeenAt` is the last time a session of theirs was used, or null if they have never
signed in. It is there so *you* can decide what counts as dormant — we do not bill on
it. Page with `?limit=` and `?before=<nextCursor>`.

**`/v1/auth/users` takes a secret key only, and answers no CORS.** Listing and deleting
other people's accounts is exactly what must not be reachable from a browser, so there
is no preflight response for it — a secret key pasted into a frontend bundle still
cannot make these calls.

This also matters for your bill: auth is charged per user account stored, so an account
you delete stops costing you at the next nightly count.

## What is here today

Email and password, social login with Google and GitHub, passkeys, two-factor by TOTP,
organizations with roles and invitations, enterprise SSO by OIDC and SAML, SCIM
directory sync, sessions, JWT issuance with JWKS, user management, and transactional
email.

The build order is complete. What comes next is driven by what customers ask for. The token payload already has
room for the fourth of those.

## While a project is being set up

A project whose auth database is still being created answers **503** with a
`Retry-After`. Provisioning takes a few seconds and happens once.

```json
{ "error": "Auth isn't ready for this project yet." }
```

The same 503 covers a suspended project, deliberately — the response does not tell a
browser which of our internal states a project is in.

## Rate limits

Two limits apply, and they answer different questions.

**Per API key: 600 requests a minute**, shared with the rest of the platform. Every
response carries `X-RateLimit-Remaining` so you can slow down before being told to; a
refusal adds `Retry-After`. This bounds what your project as a whole can do.

**Per end user, per endpoint**, which is what protects your users from each other.
Sign-in is limited to **3 attempts every 10 seconds from one IP address**, so somebody
guessing passwords against one of your accounts is held to a crawl while everyone else
signs in normally. Other endpoints get 100 requests a minute. A refusal is a **429**
with `X-Retry-After` in seconds.

```json
{ "message": "Too many requests. Please try again later." }
```

The counters live in your project's own database, so your traffic and another
customer's never share one.

This is a throttle rather than a lockout — no account is ever disabled by failed
attempts, because that turns a nuisance into a way to lock your users out of their own
accounts.
