Authentication
JWT access + refresh. Magic link, OAuth, Web3, passkeys, TOTP, and API keys.
Auth lives in apps/api as JWT Bearer over Drizzle sessions. Fastify is the only issuer and revocation authority. Web cookie work uses Next client → Next route → @repo/core SDK → Fastify; the Next api.session cookie is an SSR/security copy, not a second session store. Domain data (profile, API keys, AI) stays browser SDK → Fastify Bearer. Next.js proxy.ts is a UI gate that verifies JWTs with the shared JWT_SECRET. It does not revoke sessions.
Methods: magic link, OAuth (GitHub, Google, Facebook, Twitter), Web3 (EIP-155 / Solana), passkeys, TOTP (2FA only — not a standalone sign-in method), API keys (bask_<prefix>_<secret>, hashed at rest). Linking (email, wallet, OAuth) is Account linking. Exact paths: /reference or OpenAPI.
Tokens
- Access JWT (
typ=access):sub,sid, optionalwalfor wallet sessions. Lifetime:ACCESS_JWT_EXPIRES_IN_SECONDS. Fastify verifies JWT then loads the session. - Refresh JWT (
typ=refresh):sub,sid,jti. Rotation atPOST /auth/session/refreshuses a compare-and-swapUPDATEonid,token(hashedjti), anduserId(sub). Overlapping presents of the previousjtiwithinREFRESH_REUSE_GRACE_SECONDS(default 10s) receive the same new tokens. Replay after the window returns 401TOKEN_REUSE_DETECTEDand revokes the session; further refresh attempts returnSESSION_NOT_FOUND. - Web refresh hop: the browser never calls Fastify refresh. On 401,
createClientcalls same-originPOST /api/auth/refresh, which usescreateBffClient→ Fastify refresh andSet-Cookie.proxy.tssharesrefreshTokensWithRefreshToken. Non-Next JWT consumers still call Fastify refresh directly.httpOnlystays false so the browser can send Bearer to Fastify for domain data. - Session: created on login, rotated on refresh, deleted on logout (
POST /auth/session/logoutreturns 204 for JWT sessions) or viaDELETE /auth/sessions/:id/ publicPOST /auth/sessions/revoke.
New-device email
createSessionAndIssueTokens writes IP, user-agent, deviceLabel, location (Vercel/Cloudflare geo headers only), and deviceFingerprint (browserFamily|osFamily). Fastify emails only when no other session row for that user shares the fingerprint (the new row is excluded). Users without email skip mail. WEB_APP_URL (default http://localhost:3000) must pass isAllowedUrl or the email is skipped. The CTA is ${WEB_APP_URL}/auth/session/revoke?verificationId=&token= (session_revoke verification, hashToken, CAS consume). Send is fire-and-forget via emailProvider (render + send errors are caught and logged). There is no queue; completion is not guaranteed under serverless freeze.
GET /auth/sessions and DELETE /auth/sessions/:id are JWT-only (400 USE_KEY_REVOKE for API keys). List never returns userAgent or deviceFingerprint.
Magic link verify
createClient modes: no-auth, JWT (getAuthToken / getRefreshToken / onTokensRefreshed, optional refreshTokens), API key. See Packages.
Magic link verify
POST /auth/magiclink/verify accepts exactly one identifier:
{ token, verificationId }— link-click flow (verificationId from callback URL){ token, email }— code entry on the login page
Both/neither returns 400 INVALID_INPUT.
6-digit magic-link and change-email codes are stored as HMAC-SHA256 keyed by ENCRYPTION_KEY (hashLoginCode), not unsalted SHA-256. High-entropy secrets (API keys, refresh jti, OAuth state) still use hashToken (SHA-256). Codes issued before a deploy that changes the algorithm fail verify until the user re-requests (15-minute TTL).
Validate tokens
POST /auth/session/validate-tokens checks an access + refresh pair before Next.js writes cookies from in-page issuance (magic-link code, passkey, Google one-tap). Refresh itself does not use that adapter. Same-origin Next routes only; Fastify remains issuer and revocation authority.
Logout
POST /auth/session/logout (Bearer required) deletes the JWT session row and returns 204. API keys (bask_ / X-API-Key) cannot be logged out via this route — revoke the key instead (400 USE_KEY_REVOKE). Subsequent Bearer requests return 401.
API keys
POST /account/apikeys returns the raw bask_ key once. The database stores hashToken(secret), not the raw key.
Web3 callback storage
EIP-155 and Solana verify routes store short-lived callback JWTs in web3_callback encrypted at rest (AES-GCM). POST /auth/web3/exchange decrypts before returning tokens to the client. Decrypt failure returns 401 INVALID_OR_EXPIRED_CODE.
Passkey authentication
Passkey verify consumes the auth challenge before WebAuthn verification (one-shot; concurrent reuse returns EXPIRED_CHALLENGE). Credential counters use compare-and-swap on update. Redirect flows store encrypted tokens in passkey_callback (same AES-GCM helpers as Web3). POST /auth/passkey/exchange decrypts before returning tokens; decrypt failure returns 401 INVALID_OR_EXPIRED_CODE. Exchange requires a non-empty stored callbackOrigin and a matching request Origin / X-Callback-Origin. Stored callback origins must use HTTPS in production; outside production, HTTP is allowed only for loopback hosts (localhost, 127.0.0.1, ::1). See Login route rate limit for which auth routes share the tight Fastify cap.
Web (Next.js)
Callback routes under /auth/callback/* exchange with Fastify, set cookies, redirect to /. /auth/logout revokes the session (refresh first if the access JWT is expired) and clears cookies. /auth/session/revoke consumes the email token (works logged out) and clears api.session when the current JWT session was the one revoked. Browser 401 refresh uses same-origin POST /api/auth/refresh (cookie → SDK → Fastify); it does not call Fastify refresh from the browser. In-page issuance still uses POST /api/auth/update-tokens (same-origin, Fastify POST /auth/session/validate-tokens before Set-Cookie). After login, home is the news dashboard. Settings covers profile, sessions, and API keys. Change-email lives in Settings. Link-email and Web3 link exist on the API and in @repo/react — web has no link-wallet or link-email UI. See Frontend.
Web auth gate
apps/web/proxy.ts is the only route gate. Matcher: /((?!api|_next/static|_next/image|favicon.ico|.*\\.svg$).*). Public paths: /auth/callback/*, /auth/logout, /auth/session/revoke, /terms, /privacy, /auth/login, /images/auth-login-hero.webp. Unauthenticated users → /auth/login; authenticated users on /auth/login → /. Refreshes tokens on navigation.
OAuth environment variables
| Variable | Purpose |
|---|---|
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | GitHub OAuth |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Google OAuth |
FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | Facebook OAuth |
TWITTER_CLIENT_ID / TWITTER_CLIENT_SECRET | Twitter / X OAuth |
OAUTH_*_CALLBACK_URL | Single callback URL per provider |
OAUTH_*_CALLBACK_URLS | Comma-separated callback URLs per provider |
ENCRYPTION_KEY | Token encryption and HMAC for magic-link codes |
WEB_APP_URL | Origin for new-device revoke and sessions links (must be in ALLOWED_ORIGINS) |
Magic link: request email → user clicks → verify → cookies. E2E uses test@test.ai when ALLOW_TEST=true. See E2E Testing.
Related
- Account linking
- Frontend (Web3 hooks)
- Security