Sign-in works, then the user is signed out
Everyone is signed out after each deploy
Everyone is signed out after each deploy
secret changed. Session tokens are signed with it, so a new value invalidates every cookie. Keep it in an environment variable that is the same across deploys and across instances. theAuth takes a single secret, not a list, so there is no graceful rotation: plan a rotation as a sign-out of all users. The same applies to signingSecret for the MCP module.Sessions work on one instance and fail on another
Sessions work on one instance and fail on another
auth.session.secret is identical everywhere. If you use the rateLimit() plugin or device flow, also check that secondary storage is not left at the in-memory default.Signed in on app.example.com, signed out on api.example.com
Signed in on app.example.com, signed out on api.example.com
cookieOptions.domain to .example.com for subdomains. For different registrable domains (example.com and example.org) cookies cannot be shared at all, use JWT sessions with an Authorization header.The session is fine in the browser but middleware cannot read it
The session is fine in the browser but middleware cannot read it
CORS and cross-origin requests
theAuth core adds CORS headers only to the MCP endpoints (Access-Control-Allow-Origin: *, with WWW-Authenticate exposed so browser MCP clients can read the challenge). The sign-in, session and management routes send no CORS headers. If your front end is on a different origin from the API, you add the headers yourself in your framework, for example with Hono’s cors() middleware or Express’s cors package, and you must allow credentials for cookies:
credentials: 'include' on fetch calls. A wildcard origin together with credentials is rejected by browsers, which shows up as “CORS error” even though the request reached the server.
For a cookie to be sent cross-site at all it needs SameSite=None; Secure, and recent browsers also block third-party cookies by default in some modes. If the front end and API can share a registrable domain, do that and keep SameSite=Lax. Otherwise use JWT sessions.
The device approval endpoint is stricter on purpose. POST /auth/device/authorize answers 403 access_denied to a browser Origin that is neither the origin of verificationUri nor listed in trustedOrigins. If your verification page is on another origin, add it to trustedOrigins on deviceAuth() (Device authorization).
OAuth and redirects
redirect_uri_mismatch (reported by Google, GitHub and others)
redirect_uri_mismatch (reported by Google, GitHub and others)
oauth() plugin it is {baseUrl}/auth/oauth/callback/{provider}. Because baseUrl includes the adapter mount path, with baseUrl: 'https://app.example.com/api/theauth' and Google the URL is https://app.example.com/api/theauth/auth/oauth/callback/google. Trailing slashes, http versus https, and localhost versus 127.0.0.1 all count as different. To compute something else, pass buildRedirectUri to oauth()."OAuth callback: unknown or already-used state value."
"OAuth callback: unknown or already-used state value."
state in the callback is not in the database. Common causes: the user pressed back and retried the same callback URL (state is deleted on first use), the callback went to a different deployment with a different database, or the state row was cleaned up. Restart the flow from the authorize URL."OAuth callback: state was issued for provider X, not Y."
"OAuth callback: state was issued for provider X, not Y."
After sign-in the user lands on a 404
After sign-in the user lands on a 404
{baseUrl}/ with an auth_user query parameter. If baseUrl includes the mount path (which the callback URL needs), that is a path under your mount. Handle the redirect in your app, or use createRedirectChain to send people to where they were going.MCP client says redirect_uri is invalid (INVALID_REDIRECT_URI)
MCP client says redirect_uri is invalid (INVALID_REDIRECT_URI)
localhost for development) and must not contain a fragment. During the authorize and consent steps the redirect_uri has to match one registered for the client exactly. See MCP.Clock skew
JWT checks use the server’s clock. The session and MCP token verification do not add a tolerance, so a server clock that runs ahead can reject a freshly issued token as not yet valid, and one that runs behind keeps expired tokens alive for longer. Run NTP (or your cloud’s time sync) on every host that issues or verifies tokens. In containers, the host clock is what counts. SAML single sign-on is the exception. It allowsclockSkewSeconds of drift, 120 by default, when checking the assertion’s validity window (SSO). If an IdP is rejected with a “not yet valid” or “expired” assertion error, fix the clock first and raise clockSkewSeconds only as a stopgap.
Device and one-time codes use expiry timestamps stored by the server, so skew between the device and the server does not matter for them.
Proxies, load balancers and client IPs
Rate limits are per client IP, and theAuth does not trustX-Forwarded-For by default because clients can forge it. When no trusted source is configured the plugin falls back to one shared "unknown" bucket. Behind a proxy that means every user shares one limit and a busy site returns 429 RATE_LIMITED to everyone at once.
Tell theAuth how your edge works, in one of two ways:
rateLimit() plugin takes the same two options, trustedProxyCount and trustedHeader. Count from the right: the last entry was added by the proxy closest to you, the leftmost entries were written by the client. Do not set trustedHeader unless the app is truly unreachable except through that edge, otherwise anyone can send the header themselves. Standard header names: cf-connecting-ip (Cloudflare), x-real-ip (nginx), fly-client-ip (Fly.io). Values that are not a plain address-like string are discarded.
withRateLimit resolves the IP the same way: it ignores forwarded headers unless you pass trustedProxyCount or trustedHeader (or the instance sets trustedProxy). Without one of them every caller shares the "unknown" key, so set it when you run behind a proxy, or pass your own keyExtractor built on resolveClientIp (Rate limiting).
Also behind a proxy: set baseUrl to the public URL with https://, or cookies and OAuth callback URLs will be built from the wrong scheme.
Serverless and edge runtimes
Rate limits and device codes behave randomly on Vercel or Workers
Rate limits and device codes behave randomly on Vercel or Workers
"database", Redis (Upstash works over HTTP), or on Workers a D1 database. Cloudflare KV is eventually consistent and cannot count atomically, so treat KV limits as soft. See Which storage should I pick.SQLite file database on Vercel
SQLite file database on Vercel
sqlite provider keeps data in memory and rewrites the file, so nothing survives. Use Postgres or MySQL (a serverless-friendly host such as Neon helps with connection counts), or D1 on Cloudflare.Postgres connection errors under load
Postgres connection errors under load
database.url at a pooled connection string (PgBouncer or your provider’s pooler) and keep the pool small.Next.js middleware cannot use theAuth
Next.js middleware cannot use theAuth
Missing package: "TheAuth: provider ... requires the ... package"
Missing package: "TheAuth: provider ... requires the ... package"
sqlite-native, postgres and mysql providers need better-sqlite3, pg and mysql2 respectively, installed in your app. The sqlite provider uses sql.js, which ships with theAuth, and d1 uses drizzle-orm/d1. See Database setup.Startup errors
Error codes you will meet
These are returned by the code, not invented for the docs. For the complete list see Error codes. Email and password (@glinr/theauth-email, JSON body { code, message }):
PASSWORD_RESET_REQUIRED (403) when the user’s force_password_reset flag is set, which is how imported users are forced through a reset (Migrate from Auth0).
Passwordless: magic link answers 401 Invalid or expired magic link, email OTP answers 401 Invalid or expired OTP code. Both are deliberately vague about which part failed.
Rate limits: 429 with { "error": { "code": "RATE_LIMITED", "message": "Too many requests" } } from rateLimit() and withRateLimit, with a Retry-After header. Plugin endpoints that declare their own limit answer { "error": "Rate limit exceeded" }.
Session freshness: SESSION_NOT_FRESH when an action needs a recent sign-in. Ask the user to re-authenticate.
Management routes: 401 UNAUTHORIZED “Authentication required” from the adapter guard when the caller is not signed in or authenticate returned null.
Device flow (/auth/device/token): authorization_pending (keep polling), slow_down (the interval grew by 5 seconds, use the new one), access_denied, expired_token. /auth/device/authorize answers 401 login_required, 403 access_denied, 400 invalid_request, 429.
MCP and OAuth server:
createJwtSessionModule, returned as Result errors): INVALID_INPUT for an empty token, INVALID_TOKEN for a bad signature, wrong issuer or audience, or a token without sub, and TOKEN_EXPIRED once exp has passed.