Authentication settings
Two separate things control your login experience: the email-link/redirect host (set in the admin Authentication settings) and token verification (set by the platform + your app registration).
Set in the admin → Authentication settings
These point your reset/invite/onboarding emails and the redirect-to-login at the right host:
- Auth base URL — where your login/reset pages live (e.g.
http://localhost:3001in dev). - Password reset / Set password / Accept invite / Onboarding paths — the routes on that host.
These affect where email links and redirects point — get them wrong and reset/invite emails lead to a dead host. They do not affect whether a token verifies.
“Set password” is not an optional fourth path — it is required for every first-time password, which for a brand-new tenant means every user. A user with no password yet (every user you create without setting one directly, and every fresh tenant's first owner account) has exactly one self-serve route to a password: forgot-password. That flow deliberately treats “no password set” the same as “forgot my password” — same token, same endpoint — and it sends the set-password link, not the reset link, whenever the account has never had one. If the set-password path is blank, that request fails with
400 “set password path is not configured for user pool …” — and every account in the affected user pool is locked out of ever obtaining a first password, silently, until the field is filled in. Configure it during onboarding, before creating your first user — see Onboarding runbook. If your product has no page dedicated to setting a first password, point this at whatever page does handle it — accept-invite and onboarding are common choices, since the same signed token consumes any of them.What makes a token verify (configured elsewhere)
Your product verifies each token against three things — these must line up or every request 401s:
- Issuer (
iss) — the token's issuer must equal your product's expected issuer. - Audience (
aud) — must be one of your registered application keys that your product allows. Register your apps first so tokens carry a matchingaud. - JWKS — your product fetches the signing keys from the platform's
/.well-known/jwks.jsonand matches the token'skid. Keys are Ed25519 (EdDSA); thekidmatch keeps verification working across key rotation.
Every one of these values — issuer, endpoints,
jwks_uri, and the signing algorithm — is published in the OIDC discovery document at /.well-known/openid-configuration, so a conformant client can configure itself without hard-coding URLs. The token claims themselves are detailed in Sessions & tokens.Minimum for login: register your app key(s), make sure the token issuer matches what your product expects, and that the JWKS endpoint is reachable. The Authentication settings page only sets the email-link/redirect host — not these verification inputs.
Verify a token (EdDSA / Ed25519)
authreads signs tokens with EdDSA (Ed25519) — not the RS256/HS256 that most JWT tutorials assume. Use a library that supports EdDSA and fetches the JWKS for you (jose for JS/TS, PyJWT with cryptography for Python). Both examples cache the key set and refetch automatically when they see a new kid:
Node / TypeScript · jose
import { createRemoteJWKSet, jwtVerify } from "jose";
// Create ONCE and reuse — it caches keys and refetches on an unknown kid.
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json"),
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com", // must equal the token's iss, exactly
audience: "<your-registered-app-key>", // the aud your product allows
algorithms: ["EdDSA"], // pin it — never accept anything else
clockTolerance: "60s", // small leeway for clock skew
});
// payload.sub, payload.app_grants, payload.role, payload.amr, …Python · PyJWT
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://auth.example.com/.well-known/jwks.json") # reuse this client
signing_key = jwks.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["EdDSA"], # pin the algorithm
issuer="https://auth.example.com",
audience="<your-registered-app-key>",
leeway=60, # clock-skew tolerance, in seconds
)The four mistakes behind almost every verify failure:
- Not pinning the algorithm. Always require
EdDSAand reject everything else. Never allowalg: none, and never let the token's own header choose the algorithm — that's a well-known bypass. - An
iss/audthat only looks right. A trailing slash,httpvshttps, orlocalhostvs127.0.0.1all fail the check even though the strings look identical — the dev base URL is published as127.0.0.1(see Environments & base URLs), and an allowlist carrying the other spelling rejects every token. Copy the exactissuerfrom the discovery document, and setaudto your registered app key. - No clock leeway. Cloud clocks drift; allow 30–60s on
exp/nbf/iat. If you need minutes, fix the clock (sync NTP) instead of widening the window. - Caching JWKS without refetching on a new
kid. On key rotation authreads publishes the new public key before it signs with it and keeps the previous key in the JWKS during an overlap window — so a verifier that refetches on an unknownkid(the libraries above do) rides through rotation with zero downtime. A verifier that caches on a fixed timer rejects every new token until its cache expires.
Verify tokens on your server, never in browser JavaScript — a browser can't keep anything secret and shouldn't be the thing deciding whether a token is valid. See what's safe in the browser.