Skip to content

Signing in from a browser

The /api/auth/* endpoints (Auth scope) authenticate a person at a keyboard, in a browser, on one of our own front ends. They are not a replacement for the credentials an integration uses: a server-to-server client keeps using a personal access token, and the reference for these endpoints is generated by the auth library, so it describes their request and response bodies but none of the four rules on this page.

If you are building against the API from your own backend, you want Getting started instead.

Four things the reference does not tell you

Section titled “Four things the reference does not tell you”

/api/auth/* sits behind the same version gate as every other endpoint. The auth library’s own client does not send the header, so a default createAuthClient() gets 406 on every call before the auth library ever sees the request.

const authClient = createAuthClient({
baseURL: "https://api.re-zip.com",
fetchOptions: {
headers: { "Accept-Version": "2.0" },
},
})

Requests are rejected unless the browser’s Origin is a re-zip.com subdomain — 403 with {"code": "INVALID_ORIGIN"}. Local development from http://localhost:3500 and http://localhost:3600 is allowed outside production.

This bites in a way that is easy to misread: the same request succeeds from curl, because a request with no Origin header at all skips the check. A flow that works in your terminal and fails in the browser is almost always this.

3. Read the token from set-auth-token, and send it as a bearer

Section titled “3. Read the token from set-auth-token, and send it as a bearer”

Cookies are not used. The API only forwards the Authorization header to the auth layer, so an ambient session cookie authenticates nothing — send credentials: "omit" and read the session token out of the set-auth-token response header.

const res = await fetch("https://api.re-zip.com/api/auth/sign-in/email", {
method: "POST",
credentials: "omit",
headers: { "Accept-Version": "2.0", "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
const token = res.headers.get("set-auth-token")

That token is then an ordinary bearer credential for the whole API, not just for /api/auth/*:

Authorization: Bearer <token>

Two-factor sign-in is the one place cookies are used. For a user enrolled in two-factor, sign-in answers {"twoFactorRedirect": true} and keeps the pending challenge in an HttpOnly cookie, so send credentials: "include" on the sign-in request and on the POST /api/auth/two-factor/verify-totp that completes it. Check twoFactorRedirect before reading set-auth-token: on a challenge that header names a session that has already been deleted. The cookie only round-trips same-site: from a re-zip.com subdomain, or from localhost against an API also running on localhost, and it is the only cookie the API ever sets — the session itself always arrives as the header.

After a two-factor verification, read the header and not res.body.token. Verifying a second factor replaces the session — the old one is deleted — and the response body still names the deleted one, so a client that trusts the body is immediately signed out.

A session expires 30 minutes after it was issued and the window slides with use, matching the access tokens these replace. Treat a 401 as “sign in again” rather than as an error worth reporting.

The auth library mounts a wider surface than we accept, and the reference lists all of it. Only authentication and password changes are enabled:

EndpointWhat happensUse instead
POST /api/auth/sign-up/email400 — disabledAgreements and invitations
/api/auth/update-userdisabledPATCH /me
/api/auth/change-emaildisabledPUT /me/email
/api/auth/delete-userdisabledDELETE /me

Sign-up, password reset and email verification stay on the /me endpoints. These endpoints authenticate an existing user. POST /api/auth/change-password changes their password, the same as PUT /me/password; nothing else here creates or modifies a user.

Timestamps that come from the user record are serialised as 2026-05-28 07:24:54.849432 rather than as ISO 8601, while the session’s own timestamps are ISO:

{
"session": { "createdAt": "2026-09-22T11:41:59.838Z" },
"user": { "createdAt": "2026-05-28 07:24:54.849432" }
}

new Date(...) does not parse the first form consistently across browsers. If you display a user timestamp from /api/auth/*, parse it explicitly — or read it from GET /me, which is ISO everywhere.