Chronos/DBWaitlist

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

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

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

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

your app, in the browser
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 }) });
a session
{  "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.

CallBodyAnswer
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 userAuthorization: Bearer <access token>{user}
GET .well-known/jwks.json{keys: [{kty: "OKP", crv: "Ed25519", alg: "EdDSA", kid, x}]}, cacheable 5 minutes

Tokens

TokenIsLasts
accessa JWT signed with EdDSA (Ed25519)access_ttl_s: 5 minutes to a day, an hour by default
refreshan opaque string, stored by Chronos only as a hash30 days unused; spent on use

Access token claims

ClaimValue
issthe endpoint, …/app-auth/v1/<project id>
audthe project id
subthe user’s id: what your tables store
emailthe user’s email
sidthe session it belongs to
roleauthenticated
iat, expissued 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.

Node, with jose
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 };}
Python, with PyJWT
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.

SQL
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.sub

From the browser

  • A request whose Origin is in the project’s list gets CORS headers (preflight 204); any other origin gets none (preflight 403). Requests without Origin, from servers, pass.
  • Keep the refresh token where scripts from other origins can’t read it. An httpOnly cookie set by your own server, which calls token for the browser, is the safest place.
  • Refresh shortly before expires_in runs out, and on a 401 from your API.

Settings

SettingMeaning
enabledoff: every endpoint is 404. Users and settings are kept
new sign-upsoff: signup answers 403; existing users still sign in
access token lifetime5 minutes to a day. Shorter is safer: a disabled user’s token works until it expires
allowed originsscheme://host[:port], at most 20
signing keyRotate 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>"}.

StatusWhen
400not an email address; a password outside 8 to 256 characters; an unknown grant_type
401wrong email or password; a refresh token spent, expired or reused; a missing or invalid access token
403sign-ups closed; the account is disabled; an origin not in the list
404auth is off for this project, or no such project
409that email already has an account: sign in instead
429too many failed sign-ins: wait and try again
Auth for your app · Chronos