Auth API
buildingSign-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.
1curl -X POST https://<your-deployment>/v1/auth/sign-up/email \2 -H "Authorization: Bearer pk_live_…" \3 -H "Content-Type: application/json" \4 -d '{"email":"ada@example.com","password":"…","name":"Ada Lovelace"}'1{2 "token": "onSnRF8wZMuCs03Li7lcKJT2tLTvHjQO",3 "user": {4 "id": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M",5 "email": "ada@example.com",6 "emailVerified": false,7 "name": "Ada Lovelace"8 }9}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.
1{2 "sub": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M",3 "sid": "h0bsPL9wBPaRfvslAcU2q0w8VPeF4wJu",4 "org_id": null,5 "role": null,6 "email": "ada@example.com",7 "email_verified": false,8 "iss": "https://<your-deployment>",9 "exp": 178697748810}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:
curl https://<your-deployment>/v1/auth/settings -H "Authorization: Bearer sk_live_…"{ "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:
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:
{ "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.
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 AcmeThe 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:
{ "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/googleIt 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:
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:
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"}'{ "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:
[
{
"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.
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:
{ "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:
{ "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 sessionOrganizations
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.
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.
{ "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":
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:
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:
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-samlIt carries the ACS URL they need, which is ours:
http://<your-deployment>/v1/auth/sso/saml2/sp/acs/bigcorp-samlIdP-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:
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_…"}'{
"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 .
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:
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:
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:
curl -X DELETE https://<your-deployment>/v1/auth/users/<user-id> \
-H "Authorization: Bearer sk_live_…"{ "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:
{
"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=.
/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.
{ "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.
{ "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.