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
| Field | Type | Description |
|---|---|---|
id | string | Public user ID. |
email | string | |
name | string, nullable | |
email_verified | boolean | |
created_at | string (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
- CLI
- SDK
- curl
naturali request-sign-in-code --email jane@acme.com
naturali verify-sign-in-code --email jane@acme.com --code 481902
await naturali.auth.requestSignInCode({ body: { email: 'jane@acme.com' } });
const { data: session } = await naturali.auth.verifySignInCode({
body: { email: 'jane@acme.com', code: '481902' },
});
curl -X POST https://api.naturali.ai/v1/auth/code \
-H "Content-Type: application/json" \
-d '{ "email": "jane@acme.com" }'
curl -X POST https://api.naturali.ai/v1/auth/code/verify \
-H "Content-Type: application/json" \
-d '{ "email": "jane@acme.com", "code": "481902" }'