Skip to content

Latest commit

 

History

History
466 lines (353 loc) · 24.8 KB

File metadata and controls

466 lines (353 loc) · 24.8 KB
title Authentication
description 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 (update-tokens). Core client calls Fastify POST /auth/session/refresh directly on 401.
  • 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)
  • TOTP: authenticator app (e.g. Google Authenticator) for one-time codes
  • Passkeys: WebAuthn passkeys for passwordless sign-in (registration and sign-in)

Principles

  • Backend-controlled: authentication is verified server-side
  • JWT Bearer: the API accepts Authorization: Bearer <accessToken> (JWT) or Authorization: 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> or Authorization: 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; lastUsedAt nullable (null = never used)

TOTP authenticator (implemented)

TOTP enables users to add an authenticator app (e.g. Google Authenticator, Authy) for one-time codes. One TOTP factor per user.

  • Setup flow: POST /account/link/totp/setup → returns { otpauthUri, manualEntryKey, qrCodeDataUrl } → user scans QR or enters key → POST /account/link/totp/verify with { code } → persists to totp table
  • Unlink: DELETE /account/link/totp → removes TOTP for user
  • Session user: GET /auth/session/user includes totpEnabled: boolean
  • Error codes: INVALID_CODE, EXPIRED_SETUP

Passkeys (implemented)

Passkeys use WebAuthn for passwordless registration and sign-in. Registration creates discoverable credentials (residentKey: 'required') so passkeys sync to iCloud Keychain, Google Password Manager, 1Password, etc. rpID is derived from the request Origin header (validated against ALLOWED_ORIGINS).

Registration (when logged in):

  • Add passkey: POST /account/link/passkey/start → returns { options } for startRegistration → client calls POST /account/link/passkey/finish with { credential, name? } — optional name for display
  • List: GET /account/passkeys{ passkeys: [{ id, name, createdAt }] }
  • Remove: DELETE /account/link/passkey/:id
  • Session user: GET /auth/session/user includes passkeys array
  • Stored metadata: transports (JSONB), credentialDeviceType, credentialBackedUp for cross-device UX
  • Error codes: INVALID_ORIGIN, EXPIRED_CHALLENGE, VERIFICATION_FAILED

Sign-in (username-less flow):

  • Start: POST /auth/passkey/start (no auth) → returns { options, sessionId } for startAuthentication
  • Verify: POST /auth/passkey/verify with { assertion, sessionId, callbackUrl? } → creates session, returns { redirectUrl } or { token, refreshToken }
  • Exchange: POST /auth/passkey/exchange with { code } → one-time code for tokens (callback flow)
  • Tables: passkey_auth_challenges (sessionId, challenge, 5 min TTL), passkey_callback (codeHash, tokens, one-time consumption)
  • Flow: Client calls start → stores sessionId → startAuthentication → verify with sessionId in body → window.location.assign(redirectUrl) → callback page exchanges code, sets cookies, redirects
  • Error codes: INVALID_ORIGIN, EXPIRED_CHALLENGE, UNKNOWN_CREDENTIAL, VERIFICATION_FAILED, INVALID_CALLBACK_URL

Vercel-style discovery UX (optional):

  • Resolve user: POST /auth/passkey/resolve-user with { userHandle } (base64url from assertion) → returns { maskedIdentifier } for "Login as …" display (PII-safe). Rate limited (10/min per IP).
  • Discovery flow: On login page load, client calls start → startAuthentication({ useBrowserAutofill: true }) (conditional UI) → if credential returned, call resolve-user with response.userHandle → show PasskeyShortcut card above the form.
  • PasskeyShortcut: Compact card with "Login as [email]", [Use Passkey] button, "Use another method" link. When user clicks "Use Passkey", verify without callbackUrl → receive { token, refreshToken }updateAuthTokens + router.push.
  • Direct token flow: When callbackUrl is absent, verify returns { token, refreshToken } directly (no redirect). Caller uses onSuccess to update cookies and navigate.
  • Fallback: When discovery finds nothing, passkey remains in "Or continue with" (redirect flow).

Magic link (implemented)

Magic link sign-in issues a 6-digit login code over email, then exchanges it for JWTs. Users can either type the code into the login form or click the magic link button in the email.

Flow:

  1. User enters email → POST /auth/magiclink/request → email sent with subject {code} - {APP_NAME} verification code
  2. Email contains the 6-digit code prominently and a "Sign in" button (magic link)
  3. Option A (manual): User types the 6-digit code in the login form → POST /auth/magiclink/verify with { token: code } → JWTs returned
  4. Option B (link): User clicks the button in email → callback page receives ?token={code} → verify → cookies set, redirect

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:

  1. User clicks "Continue with GitHub" → client calls useOAuthLoginGET /auth/oauth/github/authorize-url (direct to Fastify) → receives { redirectUrl } → redirects to GitHub
  2. User authorizes on GitHub → GitHub redirects to OAUTH_GITHUB_CALLBACK_URL with ?code=&state=
  3. Callback page (/auth/callback/oauth/github) calls POST /auth/oauth/github/exchange with { code, state } → Fastify returns { token, refreshToken }
  4. 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.

OAuth (Google, Facebook, Twitter)

Google One Tap: Uses Google Identity Services (GIS). Load GIS script, call google.accounts.id.prompt() — popup appears in top-right when user is logged into Google. Client POSTs { credential } to POST /auth/oauth/google/verify-id-token. Backend verifies JWT with google-auth-library, creates session. Setup: GOOGLE_CLIENT_ID (Fastify), NEXT_PUBLIC_GOOGLE_CLIENT_ID (Next.js).

Facebook: Redirect flow like GitHub. GET /auth/oauth/facebook/authorize-urlPOST /auth/oauth/facebook/exchange. Callback: /auth/callback/oauth/facebook. Setup: FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET, OAUTH_FACEBOOK_CALLBACK_URL.

Twitter (X): OAuth 2.0 with PKCE. GET /auth/oauth/twitter/authorize-url (includes code_challenge) → POST /auth/oauth/twitter/exchange (includes code_verifier from verification meta). Callback: /auth/callback/oauth/twitter. Setup: TWITTER_CLIENT_ID, TWITTER_CLIENT_SECRET, OAUTH_TWITTER_CALLBACK_URL.

When a provider is not configured (503 OAUTH_NOT_CONFIGURED), the client shows a Sonner toast instead of redirecting.

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.

sequenceDiagram
  participant Client
  participant FastifyAPI
  participant Wallet
  participant CallbackPage as /auth/callback/web3

  Client->>FastifyAPI: GET /auth/web3/:chain/nonce?address=...
  FastifyAPI->>FastifyAPI: validate address, upsert nonce (web3_nonce)
  FastifyAPI-->>Client: { nonce }

  Client->>Client: build SIWE/SIWS message (domain, address, nonce)
  Client->>Wallet: request signature
  Wallet-->>Client: signature

  Client->>FastifyAPI: POST /auth/web3/:chain/verify { message, signature, callbackUrl? }
  FastifyAPI->>FastifyAPI: verify nonce + signature, create/link user

  alt callbackUrl provided
    FastifyAPI-->>Client: 302 redirect to callback?code=...
    Client->>CallbackPage: GET /auth/callback/web3?code=...
    CallbackPage->>FastifyAPI: POST /auth/web3/exchange { code }
    FastifyAPI-->>CallbackPage: { token, refreshToken }
    CallbackPage->>CallbackPage: set cookies, redirect
  else no callbackUrl (mobile/CLI)
    FastifyAPI-->>Client: { token, refreshToken }
  end
Loading

Provider / adapter architecture

flowchart TB
  subgraph App [apps/next]
    WAI[WalletAdaptersInjector]
    Providers[providers.tsx]
  end

  subgraph Wagmi [wagmi]
    useAccount
    useSignMessage
  end

  subgraph Solana [Solana wallet-adapter]
    useWallet
  end

  subgraph AppHooks [apps/next @/hooks @/wallet]
    WalletProvider
    useWalletAuth
    useLinkWallet
  end

  subgraph RepoReact [@repo/react]
    useVerifyWeb3Auth
    useVerifyLinkWallet
    useLinkEmail
  end

  Providers --> WAI
  WAI -->|bridges| Wagmi
  WAI -->|bridges| Solana
  WAI --> WalletProvider
  WalletProvider --> useWalletAuth
  WalletProvider --> useLinkWallet
  useWalletAuth --> useVerifyWeb3Auth
  useLinkWallet --> useVerifyLinkWallet
Loading

Dashboard link flows

flowchart LR
  subgraph LinkWalletFlow [Link / unlink wallet]
    Connect[Connect wallet]
    Sign[Sign message]
    Verify[POST account/link/wallet/verify]
    Unlink[DELETE account/link/wallet/:id]
    Connect --> Sign --> Verify
    Verify -.-> Unlink
  end

  subgraph LinkEmailFlow [Link email]
    Request[POST account/link/email/request]
    Email[User clicks email link]
    VerifyEmail[POST account/link/email/verify]
    UpdateTokens[POST api/auth/update-tokens]
    Request --> Email --> VerifyEmail --> UpdateTokens
  end

  Dashboard[Dashboard page] --> LinkWalletFlow
  Dashboard --> LinkEmailFlow
Loading

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/refresh returns 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 jti and extends session expiry
  • Revoke: logout deletes the session (and refresh reuse detection revokes)
stateDiagram-v2
  [*] --> Unauthenticated: Initial load / Logout

  Unauthenticated --> Authenticated: Login success (callback sets cookie)

  Authenticated --> Authenticated: API calls (Bearer from cookie)
  Authenticated --> Refreshing: 401 on API call OR navigate with expired token
  Authenticated --> Unauthenticated: Logout / Session revoked

  Refreshing --> Authenticated: Refresh success (onTokensRefreshed / proxy sets cookie)
  Refreshing --> Unauthenticated: Refresh fail (clear cookie)
Loading

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 })
  • OAuth (Google One Tap)
    • POST /auth/oauth/google/verify-id-token{ token, refreshToken } (body: { credential })
  • OAuth (Facebook)
    • GET /auth/oauth/facebook/authorize-url{ redirectUrl }
    • POST /auth/oauth/facebook/exchange{ token, refreshToken } (body: { code, state })
  • OAuth (Twitter)
    • GET /auth/oauth/twitter/authorize-url{ redirectUrl } (PKCE)
    • POST /auth/oauth/twitter/exchange{ token, refreshToken } (body: { code, state })
  • Web3
    • GET /auth/web3/nonce{ nonce } (query: chain, address)
    • POST /auth/web3/:chain/verify{ token, refreshToken }
    • :chain is eip155 or solana
  • Account linking (Bearer required — JWT or API key)
    • POST /account/link/wallet/verify{ ok } (body: chain, message, signature)
    • DELETE /account/link/wallet/:id204 (unlink wallet by id from linkedWallets)
    • 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/:id204
  • TOTP (Bearer required)

    • POST /account/link/totp/setup{ otpauthUri, manualEntryKey, qrCodeDataUrl }
    • POST /account/link/totp/verify{ ok } (body: { code })
    • DELETE /account/link/totp204
  • Passkeys (Bearer required for registration)

    • POST /account/link/passkey/start{ options } (for startRegistration)
    • POST /account/link/passkey/finish{ ok } (body: { credential })
    • GET /account/passkeys{ passkeys: [{ id, name, createdAt }] }
    • DELETE /account/link/passkey/:id204
  • Passkey sign-in (no auth)

    • POST /auth/passkey/start{ options, sessionId }
    • POST /auth/passkey/verify{ redirectUrl } or { token, refreshToken } (body: { assertion, sessionId, callbackUrl? })
    • POST /auth/passkey/resolve-user{ maskedIdentifier } (body: { userHandle }, for discovery UX)
    • POST /auth/passkey/exchange{ token, refreshToken } (body: { code })
  • Session

    • GET /auth/session/user (Bearer) → { user } (includes optional wallet, linkedWallets with { id, chain, address } for unlink, totpEnabled, passkeys with { id, name, createdAt })
    • POST /auth/session/logout (Bearer) → 204
    • POST /auth/session/refresh{ token, refreshToken }

Next.js Auth (web)

Callback pages (/auth/callback/magiclink, /auth/callback/oauth/github, /auth/callback/web3, /auth/callback/passkey) 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/update-tokens: accepts { token, refreshToken }, sets single cookie. Used after 401 refresh (core's onTokensRefreshed) and after link email or profile edit.

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 Fastify POST /auth/session/refresh directly, then onTokensRefreshed POSTs to /api/auth/update-tokens to persist new tokens in the cookie. Both are needed.

Route protection: Proxy handles all auth redirects. Do not duplicate getAuthStatus() + redirect in layouts or pages.

Cookies: Single cookie api.session (configurable via AUTH_COOKIE_NAME / NEXT_PUBLIC_AUTH_COOKIE_NAME) stores JSON { token, refreshToken }. Readable on the client (httpOnly: false) so getAuthToken can read from document.cookie. Cookie maxAge is derived from refresh JWT exp.

Core auth modes: createClient supports three modes—(1) apiKey: static Bearer, no refresh; (2) JWT: getAuthToken, getRefreshToken, onTokensRefreshed required, refresh on 401; (3) no-auth: baseUrl only.

Cookie architecture

flowchart TB
  subgraph Storage [Single Cookie]
    Session["api.session"]
  end

  subgraph Parse [Parse JSON]
    Session -->|"value"| Parse
    Parse --> Access["token"]
    Parse --> Refresh["refreshToken"]
  end

  Access --> getAuthToken["getAuthToken returns token"]
  Refresh --> getRefreshToken["getRefreshToken returns refreshToken"]
  getAuthToken --> Bearer["Authorization: Bearer"]
Loading

Core auth mode decision flow

flowchart TD
  Create[createClient]
  Create --> HasApiKey{apiKey provided?}
  HasApiKey -->|Yes| UseApiKey["Use apiKey for Bearer"]
  HasApiKey -->|No| HasJwt{JWT callbacks?}
  HasJwt -->|Yes| RequireJwt["Require getAuthToken, getRefreshToken, onTokensRefreshed"]
  HasJwt -->|No| NoAuth[No auth - no Bearer header]
  UseApiKey --> NoRefresh[Never attempt refresh]
  RequireJwt --> RefreshOn401[401 triggers refresh via Fastify]
  NoAuth --> NoRefresh
Loading

JWT 401 refresh sequence

Core calls Fastify directly for refresh; Next.js cookie updated via onTokensRefreshedupdate-tokens.

sequenceDiagram
  participant Core as core client
  participant Cookie
  participant Fastify
  participant Callback as onTokensRefreshed (updateAuthTokens)
  participant BFF as POST /api/auth/update-tokens

  401->>Core: API call fails
  Core->>Cookie: getRefreshToken (parse api.session)
  Core->>Fastify: POST /auth/session/refresh (direct)
  Fastify-->>Core: { token, refreshToken }
  Core->>Callback: onTokensRefreshed(tokens)
  Note over Callback,BFF: Cookie persisted on Next.js side
  Callback->>BFF: POST /api/auth/update-tokens
  BFF->>Cookie: Set api.session (single cookie)
  Core->>Core: retry original request
Loading

Magic link flow (web)

A 6-digit code is sent by email. Users can enter the code on the login page or click the magic link in the email. Both paths exchange the code for JWTs.

sequenceDiagram
  participant Browser
  participant LoginForm
  participant CallbackPage as /auth/callback/magiclink
  participant FastifyAPI as Fastify API
  participant DB as PostgreSQL
  participant Email as Email provider

  Browser->>FastifyAPI: POST /auth/magiclink/request (email, callbackUrl)
  FastifyAPI->>DB: upsert user + store hash(6-digit code)
  FastifyAPI->>Email: send email (code + magic link button)
  Email-->>Browser: email with code and link

  alt Manual code entry
    Browser->>LoginForm: User enters 6-digit code
    LoginForm->>FastifyAPI: POST /auth/magiclink/verify (token: code)
    FastifyAPI->>DB: consume code, create session
    FastifyAPI-->>LoginForm: { token, refreshToken }
    LoginForm->>LoginForm: updateAuthTokens, redirect
  else Link click
    Browser->>CallbackPage: GET /auth/callback/magiclink?token={code}&callbackUrl=...
    CallbackPage->>FastifyAPI: POST /auth/magiclink/verify (token)
    FastifyAPI->>DB: consume code, create session
    FastifyAPI-->>CallbackPage: { token, refreshToken }
    CallbackPage->>CallbackPage: set cookies, redirect to callbackUrl
  end

  Browser->>FastifyAPI: GET /auth/session/user (Bearer from cookie)
  FastifyAPI-->>Browser: { user }
Loading

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 Fastify GET /auth/session/user directly 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 refresh jti, expiry, optional wallet_chain/wallet_address for 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, optional lastUsedAt, expiresAt)
  • totp: user TOTP secret (encrypted at rest); one per user
  • totp_setup: temporary TOTP setup (encrypted secret, 10-min TTL); replaced on new setup
  • passkey_credentials: WebAuthn credentials (credentialId, publicKey, counter, name, transports, credentialDeviceType, credentialBackedUp)
  • passkey_challenges: registration challenges (challenge, 5-min TTL)
  • passkey_auth_challenges: sign-in challenges (sessionId, challenge, 5-min TTL)
  • passkey_callback: one-time code exchange (codeHash, tokens, 5-min TTL, consumed on exchange)

Security notes

  • Refresh token rotation: refresh jti is 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: callbackUrl is validated with isAllowedUrl against ALLOWED_ORIGINS (default *). Absolute http/https only; 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)