Skip to main content

Auth

Human sign-in with a code emailed to the address or with Google, plus session refresh and revocation.

Overview​

Auth issues session JWTs for human users of the app — distinct from API Keys, which are the programmatic credential for server-to-server calls. Both are accepted by every endpoint's Authorization: Bearer … header.

A human proves an address, either with a six-digit code mailed to it or with a Google account Google has verified it for. The address is the account: both ways in reach the same one, whichever came first. No password, no password reset, no separate email-verification step — each exchange covers sign-up, sign-in and proof of the address.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Auth.

Data Model​

User​

FieldTypeDescription
idstringPublic user ID.
emailstring
namestring, nullable
email_verifiedboolean
rolesstring[]Platform roles (admin). See Users.
created_atstring (date-time)

There is no password field. email_verified is true from the moment the row exists, because an account only comes into being by proving the address.

Key Concepts​

Signing in with an emailed code​

POST /v1/auth/code emails a six-digit code; POST /v1/auth/code/verify exchanges the address and code for a session. It is both sign-up and sign-in: a first-time address gets an account on its first successful verification, with email_verified already true, because holding the code proves control of the address. An address added to a project before it ever signed in is activated the same way.

The endpoint answers identically for a known and an unknown address, so it never reveals who has an account. Requesting a code invalidates any previous one.

Six digits is roughly twenty bits, which is small enough to guess given unlimited tries — so the code is short-lived (ten minutes by default) and tolerates a bounded number of wrong guesses. Reaching that ceiling destroys the code and answers 429 too_many_attempts; the client must request a new one rather than retry.

Only the hash of a code is ever stored, so a database dump yields nothing an attacker could redeem.

Both calls, for every client, are under Examples below.

Signing in with Google​

POST /v1/auth/google exchanges a Google ID token — the credential the Sign in with Google button hands the page — for the same AuthSession. The API checks Google's signature, that the token was issued to this deployment's client (GOOGLE_CLIENT_ID), and that Google has verified the address; an unverified one answers 403 email_not_verified.

The account is looked up by address. An address that signed in with a code gets its existing account, an address first seen through Google gets a new one that a code later reaches, and an address added to a project before it ever signed in is activated as it is by a code. Google's name fills an empty name, never overwrites one.

Unconfigured, the endpoint answers 503 google_sign_in_not_configured.

Why there is no password​

Password reset runs through email, so an inbox already unlocks any account that has one. Keeping passwords therefore adds credential stuffing, reuse and phishing on top of a root of trust that is email either way — more attack surface for no reduction in blast radius. The code also collapses four flows (sign-up, sign-in, email verification, recovery) into one, and autofills in a single tap on iOS and Android.

The cost is a real dependency: SES deliverability sits in front of every code sign-in, with only Google to fall back on. That is answered by monitoring and by long refresh-token lifetimes — routine use never sends an email — not by keeping a weaker credential alive. Callers who cannot accept an emailed code want SSO, which is a separate piece of work.

Session lifecycle​

POST /v1/auth/code/verify and POST /v1/auth/google return an AuthSession (an access token, refresh token, and expiry). POST /v1/auth/refresh exchanges a refresh token for a new session; POST /v1/auth/logout revokes it, or every session for the user with { "all": true }.

Refresh tokens are single-use: presenting a rotated one is treated as reuse and revokes the whole session family.

Staying signed in​

Sign-in and every refresh also set the refresh token as an httpOnly cookie, SameSite=Lax, scoped to /v1/auth and marked Secure on https. That is what keeps a browser session alive across a reload: the app holds the access token in memory only — never localStorage, which any injected script can read — so a reloaded page has no credential of its own. It posts to /v1/auth/refresh with an empty body and the browser attaches the cookie it cannot read.

The token stays in the response body as well, for clients with no cookie jar such as the CLI. Scoping the cookie to /v1/auth keeps the long-lived credential off every ordinary API call; logout clears it.

Because a session lasts as long as its refresh token (thirty days by default) and routine use never sends email, the SES dependency above is felt at sign-in, not day to day.

One consequence of putting the token in a cookie: several tabs restoring a session share it, so they present the same token at once. A token re-presented within a few seconds of being spent is therefore treated as that race and rotated again rather than as theft — otherwise opening two tabs would revoke the family and sign the user out everywhere. Past that window, reuse still kills the family. REFRESH_REUSE_GRACE sets it.

Undeliverable addresses​

SES keeps an account-level suppression list, adding any address that bounces or files a complaint and refusing later sends to it. The API relies on that list rather than keeping its own: a send to a suppressed address comes back rejected, and lib/mail.ts treats that rejection as a no-op.

This matters more here than it would elsewhere. An address that stops receiving mail is an account that cannot be signed into at all, unless it is a Google account. The list is readable and editable through SES itself — ListSuppressedDestinations gives the address, the reason and when it was added, and DeleteSuppressedDestination lifts one.

A suppressed address changes nothing a caller can see. POST /v1/auth/code answers exactly as it does for a deliverable one, because a response that differed would reveal which addresses are suppressed — which is also why the rejection is swallowed rather than surfaced as a 500.

How often one address can be mailed​

A request for a code is capped per address: a one-minute cooldown and ten requests a day by default. Without that, the endpoint is a way to mail a stranger's inbox as fast as it can be called — and the resulting complaints cost the sending domain the reputation every sign-in depends on.

Over-limit requests answer 429 too_many_requests and send nothing. The cap counts requests rather than deliveries, so a registered and an unknown address are throttled identically; counting only real sends would make a 429 mean "this address has an account".

One IP address may also send 20 requests to POST /v1/auth/code and POST /v1/auth/code/verify together over a trailing 5 minutes, so the per-address cap cannot be sidestepped by asking for codes to many addresses. Past it, both answer 429 too_many_requests with Retry-After: 60.

Examples​

naturali request-sign-in-code --email jane@acme.com
naturali verify-sign-in-code --email jane@acme.com --code 481902

Signing in with a Google ID token:

naturali sign-in-with-google --id-token eyJhbGciOiJSUzI1NiIs...