Authentication
JWT-based auth with access + refresh tokens. Supports magic links, OAuth providers, Web3 wallet sign-in, and API keys for programmatic access.
Goals
- Framework-agnostic @repo/react: Token from cookie, no Next.js-specific APIs. Works with Vue, Svelte, or vanilla browser.
- Standardized auth flow: All methods (magic link, OAuth, Web3) follow the same login pattern and share one token refresh path.
- No Fastify proxy: Clients call Fastify directly (
NEXT_PUBLIC_API_URL). Next.js API exists only for cookie updates (refresh, update-tokens). - Minimize Next.js API: Prefer auth pages (callbacks, logout) over API routes.
Overview
Authentication is implemented in apps/fastify as a JWT Bearer system backed by Drizzle + PostgreSQL/PGLite session state.
We use JWTs to support all client types (web, mobile, desktop, servers, CLIs) with a single, standard Authorization: Bearer integration.
It supports:
- Magic link: passwordless email sign-in
- OAuth: sign in with providers
- Web3: sign in with wallets
- API keys: programmatic auth for servers, CLIs, and scripts (alternate Bearer format)
Principles
- Backend-controlled: authentication is verified server-side
- JWT Bearer: the API accepts
Authorization: Bearer <accessToken>(JWT) orAuthorization: Bearer bask_<prefix>_<secret>(API key) - Access + refresh tokens: short-lived access JWTs, rotated refresh JWTs
- DB-backed sessions: tokens reference a session id that can be revoked
- Callback pages: Next.js auth pages (
/auth/callback/*,/auth/logout) set cookies; clients call Fastify directly for user data
Authentication methods
API keys (implemented)
API keys enable programmatic auth for servers, CLIs, and scripts. Users create keys from the dashboard (or via JWT-authenticated endpoints); the full key is shown once at creation and cannot be retrieved later.
- Format:
bask_<prefix>_<secret>(stored as prefix + SHA256 hash of secret) - Usage:
X-API-Key: bask_<prefix>_<secret>orAuthorization: Bearer bask_<prefix>_<secret>— same header as JWT; Fastify distinguishes by prefix - Endpoints (Bearer required — JWT or API key):
POST /account/apikeys(create),GET /account/apikeys(list),DELETE /account/apikeys/:id(revoke) - Security: timing-safe hash comparison; secrets hashed at rest;
lastUsedAtnullable (null = never used)
Magic link (implemented)
Magic link sign-in issues a single-use token over email, then exchanges it for JWTs.
OAuth (GitHub)
OAuth sign-in uses provider authorization + callback flows and issues access + refresh JWTs. Provider accounts are stored in account (provider id, account id, and encrypted tokens at rest).
GitHub OAuth flow:
- User clicks "Continue with GitHub" → client calls
useOAuthLogin→GET /auth/oauth/github/authorize-url(direct to Fastify) → receives{ redirectUrl }→ redirects to GitHub - User authorizes on GitHub → GitHub redirects to
OAUTH_GITHUB_CALLBACK_URLwith?code=&state= - Callback page (
/auth/callback/oauth/github) callsPOST /auth/oauth/github/exchangewith{ code, state }→ Fastify returns{ token, refreshToken } - Callback page sets cookies and redirects to
/
Setup: Create a GitHub OAuth App. Set callback URL to http://localhost:3000/auth/callback/oauth/github (dev) or your production URL. Add GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and OAUTH_GITHUB_CALLBACK_URL to Fastify env.
Web3 (SIWE / SIWS)
Web3 sign-in follows a nonce → sign → verify flow and returns access + refresh JWTs. Nonce and verify requests go directly to Fastify; the callback (token → cookie exchange) goes through the Next.js backend when cookies are needed.
- EIP-155 (SIWE): Sign-In with Ethereum using EIP-4361
- Solana (SIWS): Sign-In with Solana using an EIP-4361-style message format
Flow: Client fetches nonce → builds message → signs with wallet → POSTs verify to Fastify with optional callbackUrl. When callbackUrl is set: Fastify returns 302 to callback with one-time code → callback page exchanges code via POST /auth/web3/exchange → sets cookies and redirects. When absent: Fastify returns JSON { token, refreshToken } (mobile/CLI).
Framework support: Wallet integration lives in apps/next (@/hooks/, @/wallet/). @repo/react exposes verify hooks: useVerifyWeb3Auth, useVerifyLinkWallet. Pass callbackUrl to useVerifyWeb3Auth().mutate() for redirect flow.
WalletAdaptersInjector: In apps/next, mount WalletAdaptersInjector inside providers. Triggers sign-out on wallet disconnect (redirects to /auth/logout).
Error codes: INVALID_NONCE, EXPIRED_NONCE, INVALID_SIGNATURE for consistent UI handling. Wallet rejection (e.g. user denies signing) is surfaced as a non-fatal error. Account link: WALLET_ALREADY_LINKED, EMAIL_ALREADY_IN_USE, EXPIRED_TOKEN.
JWT cookie refresh: After link email or profile edit, the client receives { token, refreshToken } from the verify endpoint. Call POST /api/auth/update-tokens with those values to update cookies. In apps/next, use updateAuthTokens from @/lib/auth-client.
Data model: Nonces are stored in web3_nonce (5-min TTL); identities in wallet_identities.
Provider / adapter architecture
Dashboard link flows
Tokens & sessions
Access token (JWT)
- Type:
typ = "access" - Claims:
sub(user id),sid(session id),wal(optional session wallet when JWT created by wallet sign-in),iss,aud - Lifetime:
ACCESS_JWT_EXPIRES_IN_SECONDS - Validation: Fastify verifies the JWT, then loads the session + user from DB and attaches
request.session
Refresh token (JWT)
- Type:
typ = "refresh" - Claims:
sub(user id),sid(session id),jti(refresh token id),iss,aud - Lifetime:
REFRESH_JWT_EXPIRES_IN_SECONDS - Rotation:
POST /auth/session/refreshreturns a new access+refresh pair
Refresh token reuse is detected by hashing the jti and comparing it to the stored session token hash. On reuse, the session is revoked.
Session lifecycle
- Create: magic link verification creates a session and stores a hash of the refresh
jti - Refresh: refresh rotates
jtiand extends session expiry - Revoke: logout deletes the session (and refresh reuse detection revokes)
Endpoints
All endpoints below are served by apps/fastify. The API does not set cookies; tokens are returned as JSON.
- Magic link
POST /auth/magiclink/request→{ ok }POST /auth/magiclink/verify→{ token, refreshToken }
- OAuth (GitHub)
GET /auth/oauth/github/authorize-url→{ redirectUrl }(for client-side redirect; preferred)GET /auth/oauth/github/authorize→ 302 redirect to GitHub (legacy)POST /auth/oauth/github/exchange→{ token, refreshToken }(body:{ code, state })
- Web3
GET /auth/web3/nonce→{ nonce }(query:chain,address)POST /auth/web3/:chain/verify→{ token, refreshToken }:chainiseip155orsolana
- Account linking (Bearer required — JWT or API key)
POST /account/link/wallet/verify→{ ok }(body:chain,message,signature)DELETE /account/link/wallet/:id→204(unlink wallet by id fromlinkedWallets)POST /account/link/email/request→{ ok }(body:email,callbackUrl)POST /account/link/email/verify→{ token, refreshToken }(body:token)
Account link error codes: WALLET_ALREADY_LINKED, EMAIL_ALREADY_IN_USE
-
API keys (Bearer required — JWT or API key)
POST /account/apikeys→{ id, name, key, prefix, createdAt }(key shown once)GET /account/apikeys→{ keys: [{ id, name, prefix, lastUsedAt, expiresAt, createdAt }] }DELETE /account/apikeys/:id→204
-
Session
GET /auth/session/user(Bearer) →{ user }(includes optionalwallet,linkedWalletswith{ id, chain, address }for unlink)POST /auth/session/logout(Bearer) →204POST /auth/session/refresh→{ token, refreshToken }
Next.js Auth (web)
Callback pages (/auth/callback/magiclink, /auth/callback/oauth/github, /auth/callback/web3) exchange credentials with Fastify, set cookies, and redirect. Logout (/auth/logout) calls Fastify to revoke the session and clears cookies.
API routes (cookie updates only):
POST /api/auth/refresh: reads refresh token from cookie, calls Fastify refresh, sets new cookies. Used by core client on 401.POST /api/auth/update-tokens: accepts{ token, refreshToken }, sets cookies. Used after link email or profile edit.POST /api/auth/test-set-session: E2E-only (ALLOW_TEST), injects session from query params.
Proxy vs coreClient refresh: The proxy (proxy.ts) runs on navigation before React mounts—it refreshes expired tokens so the user isn’t redirected to login. The core client runs on client-side 401 (e.g. useUser)—it calls POST /api/auth/refresh to refresh cookies, then retries. Both are needed.
Cookies: better-auth.jwt_token and better-auth.refresh_token are readable on the client (httpOnly: false) so getAuthToken can read from document.cookie. Cookie maxAge is derived from JWT exp.
Magic link flow (web)
The email link points to the callback page, which exchanges the token for JWTs and sets cookies.
Session vs user data
- useSession (
@repo/react): Returns decoded JWT claims (sub,sid,exp,wal). No API call—reads token from cookie, decodes client-side. Use for auth gates and identity checks. - useUser (
@repo/react): Calls FastifyGET /auth/session/userdirectly with Bearer from cookie. Use for email, display name,linkedWallets.
Data model (Drizzle)
The auth system relies on these tables:
users: user identity (email-based for magic link)verification: single-use tokens (stored hashed, with TTL);type=magic_link|link_email|oauth_state(link_email reserved for Phase 1b)sessions: server-side session state (stores hashed refreshjti, expiry, optionalwallet_chain/wallet_addressfor wallet sessions)account: OAuth provider accounts (provider id + encrypted tokens at rest)wallet_identities: Web3 identities (eip155/solana+ normalized address)api_keys: user-scoped API keys (prefix, hashed secret, optionallastUsedAt,expiresAt)
Security notes
- Refresh token rotation: refresh
jtiis rotated on every refresh; reuse revokes the session - Hashed secrets at rest: magic link tokens, refresh
jti, and API key secrets are stored hashed - API key security: timing-safe hash comparison, optional expiry, atomic revoke with ownership check
- Callback URL safety:
callbackUrlis validated withisAllowedUrlagainstALLOWED_ORIGINS(default*). Absolutehttp/httpsonly; invalid schemes (e.g.javascript:), relative URLs, and disallowed origins are rejected - OAuth tokens encrypted at rest: provider access/refresh tokens are stored encrypted (AES-256-GCM)
- Nonce-based Web3 verification: nonces are short-lived and prevent replay attacks
- Environment validation: auth/security env vars are validated via Zod (
@t3-oss/env-core)
API Architecture
Single-source, type-safe REST APIs: Fastify routes (TypeBox) generate OpenAPI, which generates typed clients and React Query hooks.
Frontend Architecture
Next.js (App Router, RSC-first) with shadcn/ui + Tailwind CSS in @repo/ui and type-safe API consumption via @repo/core + @repo/react.