Build
Auth for your app
Sign-up and sign-in for your app’s users, with signed tokens your server checks. Accounts live outside your database.
SoonThese pages describe Chronos Cloud, which opens from the waitlist. Running Chronos yourself? Self-host docs
Chronos Auth gives the app you build on a project sign-up and sign-in with an email and a password, and hands it signed tokens its server checks with the project’s public keys. The accounts live outside your database, so no agent working in a world ever reads a password hash, and no merge can change one.
Turn it on
Enable it
In the dashboard, open the project’s Auth page and turn it on (owners only). Until then every endpoint answers
404, as for a project that doesn’t exist.Allow your app’s origins
Add the sites whose browser code may call it:
https://app.example.com,http://localhost:3000. Up to 20. Servers can always call it.Copy the endpoint
The page shows the endpoint,
…/app-auth/v1/<project id>, and the key URL,…/.well-known/jwks.json. Put them in your app’s config.
The flow
const AUTH = process.env.NEXT_PUBLIC_CHRONOS_AUTH; // the endpoint from the dashboardasync function call(path, body) { const r = await fetch(AUTH + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const v = await r.json(); if (!r.ok) throw new Error(v.message); return v;}// sign up, or sign inlet session = await call('/signup', { email, password });session = await call('/token', { grant_type: 'password', email, password });// before the access token expires: a new pair, the old refresh token spentsession = await call('/token', { grant_type: 'refresh_token', refresh_token: session.refresh_token });// sign out: this session endsawait fetch(AUTH + '/signout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ refresh_token: session.refresh_token }) });{ "access_token": "eyJhbGciOiJFZERTQSIs...", "token_type": "bearer", "expires_in": 3600, "refresh_token": "...", "user": { "id": "usr_...", "email": "ada@example.com", "created": "...", "last_sign_in": "..." }}Endpoints
All under the endpoint, …/app-auth/v1/<project id>/, JSON in and out.
| Call | Body | Answer |
|---|---|---|
POST signup | {email, password} | 201 and a session. Email lowercased; password 8 to 256 characters |
POST token | {grant_type: "password", email, password} | a session |
POST token | {grant_type: "refresh_token", refresh_token} | a new session answer for the same session; the refresh token is spent |
POST signout | {refresh_token} | 204: that session ends |
GET user | Authorization: Bearer <access token> | {user} |
GET .well-known/jwks.json | {keys: [{kty: "OKP", crv: "Ed25519", alg: "EdDSA", kid, x}]}, cacheable 5 minutes |
Tokens
| Token | Is | Lasts |
|---|---|---|
| access | a JWT signed with EdDSA (Ed25519) | access_ttl_s: 5 minutes to a day, an hour by default |
| refresh | an opaque string, stored by Chronos only as a hash | 30 days unused; spent on use |
Access token claims
| Claim | Value |
|---|---|
iss | the endpoint, …/app-auth/v1/<project id> |
aud | the project id |
sub | the user’s id: what your tables store |
email | the user’s email |
sid | the session it belongs to |
role | authenticated |
iat, exp | issued and expires, in seconds |
Verify on your server
Check every access token against the project’s public keys, its issuer and its audience. Then trust sub.
import { createRemoteJWKSet, jwtVerify } from 'jose';const AUTH = process.env.CHRONOS_AUTH; // the endpointconst keys = createRemoteJWKSet(new URL(AUTH + '/.well-known/jwks.json'));export async function userOf(req) { const token = req.headers.authorization?.replace(/^Bearer /, ''); if (!token) return null; const { payload } = await jwtVerify(token, keys, { issuer: AUTH, audience: process.env.CHRONOS_PROJECT }); return { id: payload.sub, email: payload.email };}import jwt, osAUTH = os.environ["CHRONOS_AUTH"]keys = jwt.PyJWKClient(AUTH + "/.well-known/jwks.json")def user_of(token: str) -> dict: key = keys.get_signing_key_from_jwt(token).key claims = jwt.decode(token, key, algorithms=["EdDSA"], audience=os.environ["CHRONOS_PROJECT"], issuer=AUTH) return {"id": claims["sub"], "email": claims["email"]}Or ask Chronos: GET user with the token returns the user, and refuses a disabled or deleted one at once rather than when the token expires.
Users in your database
Keep the user’s id in your own tables; the account itself stays in Chronos.
create table profiles (user_id text primary key, name text, plan text default 'free');create table orders (id serial primary key, user_id text not null, total numeric(10,2));-- in your server, after verifying the token:insert into orders (user_id, total) values ($1, $2); -- $1 = payload.subFrom the browser
- A request whose
Originis in the project’s list gets CORS headers (preflight204); any other origin gets none (preflight403). Requests withoutOrigin, from servers, pass. - Keep the refresh token where scripts from other origins can’t read it. An
httpOnlycookie set by your own server, which callstokenfor the browser, is the safest place. - Refresh shortly before
expires_inruns out, and on a401from your API.
Settings
| Setting | Meaning |
|---|---|
| enabled | off: every endpoint is 404. Users and settings are kept |
| new sign-ups | off: signup answers 403; existing users still sign in |
| access token lifetime | 5 minutes to a day. Shorter is safer: a disabled user’s token works until it expires |
| allowed origins | scheme://host[:port], at most 20 |
| signing key | Rotate makes a new key; the newest two stay published, so tokens signed with the previous one keep working until they expire |
Managing users
The Auth page lists the newest 500 users with when they joined and last signed in. Members and owners can disable a user (their sessions end; sign-in answers 403) or delete one (the account and its sessions go; rows your app keeps for them stay). The same through the API: POST /v1/projects/{id}/auth/users/{user} {"disabled": true} and DELETE /v1/projects/{id}/auth/users/{user}.
Security
- Passwords are hashed with Argon2id, outside your database.
- A wrong email and a wrong password get the same answer in the same time, so sign-in doesn’t tell which emails have accounts.
- Ten failed sign-ins in 15 minutes for one email, or from one address, are slowed with
429. - Signing keys are made per project and kept encrypted; private keys never leave Chronos.
Errors
Errors are {"error": "<code>", "message": "<what to do>"}.
| Status | When |
|---|---|
400 | not an email address; a password outside 8 to 256 characters; an unknown grant_type |
401 | wrong email or password; a refresh token spent, expired or reused; a missing or invalid access token |
403 | sign-ups closed; the account is disabled; an origin not in the list |
404 | auth is off for this project, or no such project |
409 | that email already has an account: sign in instead |
429 | too many failed sign-ins: wait and try again |