Skip to main content

Auth

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

Overview

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

There is one human credential: a six-digit code mailed to the address. No password, no password reset, no separate email-verification step — the same 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
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 redeeming a code that was delivered to 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.

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.

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 human sign-in, with no second credential 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 returns 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 console 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. With the code as the only human credential, an address that stops receiving mail is an account that cannot be signed into at all. 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".

Examples

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