Auth API

building

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.

Signing someone up
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"}'
The response
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.

KeyUse itOrigin check
pk_… publishableIn your app's frontendEnforced — the request must come from an origin you registered
sk_… secretOn your serverNot 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.

The claims
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:

bash
curl https://<your-deployment>/v1/auth/settings -H "Authorization: Bearer sk_live_…"
json
{ "requireEmailVerification": false, "disableSignUp": false, "minPasswordLength": 8 }
SettingDefaultWhat it does
requireEmailVerificationfalseRefuse a session until the address is confirmed
disableSignUpfalseStop new account creation; existing users still sign in
minPasswordLength8The 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.

FlowTriggerWhat the person gets
Confirm your emailAutomatic on sign-upA link that marks the address verified
Reset your passwordPOST /v1/auth/request-password-resetA single-use link, valid for an hour
Confirm account deletionPOST /v1/auth/delete-user with no passwordA 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:

organizationmembersinvitations
ownerupdate, deleteadd, update, removesend, cancel
adminupdateadd, update, removesend, cancel
memberread onlyread onlyread 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 .

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 /UsersSomeone joins — a real user account, before they ever sign in
PATCH /Users/:idactive: false when they leave, which ends their sessions
DELETE /Users/:idRemoves the directory's resource
POST /GroupsGroups 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=.

/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.