Auth
Sign your app's users up and in, and carry who they are and which organization they act in through every request.
Every app has its own users, in its own database, separate from your Authoric console account.
They sign up and sign in against your app's routes (/{appUid}/auth/...) and get tokens that
work for that app and nothing else. This page covers the model and the two calls every app makes
first: sign up, and sign in.
The model in one paragraph
A user is a row in your app's users table, addressed by its uid. Every user belongs to at
least one of your app's organizations, and a session always acts in exactly
one of them, the active organization. Signing in, by any method, ends at the same session
issuer, which decides the active organization, checks two-factor authentication, and returns the
same payload. What a signed-in user may then do is decided by the policies
on their roles, evaluated on every request.
Sign up
POST /{appUid}/auth/signup creates a user and a new organization of their own, makes them its
owner, and signs them in.
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/signup" \
-H "Content-Type: application/json" \
-d '{
"email": "dana@example.com",
"password": "Str0ng!Passphrase",
"fname": "Dana",
"lname": "Reyes"
}'Sign-up also emails a "confirm your email" link to the address. Pass verifyRedirectTo, a page of
yours on an allowed domain, to have the link open there;
The user's own account covers confirming. When
your app requires a confirmed address before anyone signs in, sign-up answers
{ "verificationRequired": true, "email": "..." } instead, with no tokens.
The password must be at least 8 characters with an uppercase letter, a lowercase letter, a number and a special character. Unknown fields are refused, including any attempt to name an existing organization: sign-up never joins someone else's organization. To bring a person into an existing organization, invite them.
Who can sign up
Password sign-up is open on every app by default. It is allowed by one seeded grant
(auth:signup), so you can close it without closing sign-in. Whether the email-link, email-code
and Google/GitHub doors may create new accounts is a setting of the deployment, reported by
GET /{appUid}/auth/methods under signup.
Sign in
POST /{appUid}/auth/login takes an email and password. Sign-in methods
covers the passwordless and social doors; they all return the same payload.
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/login" \
-H "Content-Type: application/json" \
-d '{ "email": "dana@example.com", "password": "Str0ng!Passphrase" }'A wrong email, a wrong password, an account with no password, and a password the user switched
off all answer the same 401 Invalid email or password, in the same time, so the response never
reveals which addresses have accounts.
What comes back
{
"uid": "0199a3c2-5b1e-7c4d-9f2a-3e8b6d1c0a47",
"email": "dana@example.com",
"fname": "Dana",
"lname": "Reyes",
"status": "active",
"created_at": "2026-10-02T14:11:09.000Z",
"updated_at": "2026-10-02T14:11:09.000Z",
"org": { "uid": "0199a3c2-5b1d-74e0-8c55-1f0e2a9b7d31", "name": "Dana Reyes" },
"orgUid": "0199a3c2-5b1d-74e0-8c55-1f0e2a9b7d31",
"activeOrgUid": "0199a3c2-5b1d-74e0-8c55-1f0e2a9b7d31",
"subjectType": "user",
"auth": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"activeOrgUid": "0199a3c2-5b1d-74e0-8c55-1f0e2a9b7d31"
}
}The account is an allow-list projection: no internal ids, no password hash. Two shapes replace it in
specific cases, and a client should check for them before reaching for auth:
| Field present | Meaning | What to do |
|---|---|---|
mfaRequired: true | The account has a second factor. There is no auth key and no token. | Answer the challenge at /auth/mfa/verify. See Two-factor authentication. |
mfaEnrollmentRequired: true | The active organization requires two-factor and the user has no factor yet. The tokens work, but only for setting one up. | Send the user to enroll. See Requiring two-factor. |
Calling your API
Send the access token as a bearer token on every request:
Authorization: Bearer <access token>Access tokens last 15 minutes. Exchange the refresh token at POST /{appUid}/auth/token for a new
pair before then. Refresh tokens are single-use, so store both new tokens every time and run
only one refresh at once. Sessions & token refresh has the full client
strategy.
Treat both tokens as opaque. Authoric holds the signing key and checks every request, including whether the session is still live, so there is nothing for your backend to verify locally: forward the token and act on the response.
The active organization lives in the token
A token states who the user is and which organization they are acting in. Both are set when the session is issued and when it is refreshed, and no query parameter or body field on an ordinary request can change them.
- A user with one organization is signed into it.
- A user with several is signed into their own (the one created at sign-up) unless the sign-in
names another with
activeOrgUid. - To switch, refresh with
activeOrgUid. The server checks the membership first, and also checks the target organization's two-factor requirement, so switching cannot step around it.
Why it is not a request parameter
If any request could name its organization, any request could claim any organization. Fixing it in the session makes the choice explicit and auditable, and every policy decision and every row scoped to an organization is evaluated against the same answer.
Errors you will meet here
The error body is { "error": { "error": "<message>", "code": "<CODE>", "details": {...} }, "requestId": "..." }.
Branch on error.code where there is one.
| Status | Code | When |
|---|---|---|
| 401 | (none) | Wrong email or password. Always the same message. |
| 401 | AUTH_TOKEN_EXPIRED, AUTH_TOKEN_INVALID | The access token on a request is stale or bad. Refresh and retry once. |
| 401 | SESSION_ENDED | The session is over. Sign the user in again. |
| 402 | SUBSCRIPTION_LIMIT_REACHED | Your plan's monthly-active-user ceiling is reached and this user is new this period. Users already active this period keep working. See Your plan. |
| 403 | (none) | activeOrgUid names an organization the user has not accepted membership of. |
| 400 | SIGNUP_ORG_NOT_ALLOWED | Sign-up tried to join an existing organization. |
| 403 | EMAIL_NOT_VERIFIED | Your app requires a confirmed address, and this account has not confirmed its own. |
| 403 | ACCOUNT_DISABLED | The account has been disabled. |
| 403 | BOT_CHECK_FAILED | Your app's bot check is on and the request carried no passed check. |
| 503 | BOT_CHECK_UNAVAILABLE | The bot check could not be confirmed with Cloudflare. Try again in a moment. |
| 429 | (none) | Too many sign-in or sign-up attempts from one IP address for this app. The RateLimit-* response headers say when the window resets. |
A request that fails validation (a missing field, a weak password) answers 400 VALIDATION_ERROR,
with the field in details.field and the reason in the message.
Signing in through your own server
The sign-in throttle counts attempts per IP address, per app. If your backend calls
/auth/login on your users' behalf, every one of those attempts comes from your server's address
and shares one budget (by default 30 per 15 minutes). Call the auth routes from the user's own
browser or device where you can.
What's next
Sign-in methods
Magic links, email codes, Google and GitHub, password reset.
Sessions & token refresh
Lifetimes, single-use refresh, and signing out.
The user's own account
Name, password, email address and deleting the account.
Two-factor authentication
Authenticator apps, SMS, recovery codes, remembered devices.
Bot checks
Your own Turnstile widget on your sign-up and sign-in forms.
Organizations
What a user belongs to, and who sees what.