Security Baseline
Pre-commit and CI scanning for secrets and CVEs, plus DeepSec source-code review. OSS tools only.
Layers: local pre-commit, security.yml on every PR and push to main, DeepSec on trusted same-repo PRs (deepsec.yml), and Fastify request hardening. Install scanners with pnpm run setup. Manual install: gitleaks, OSV Scanner, TruffleHog.
Pre-commit
simple-git-hooks runs hooks:pre-commit: block secret file types → gitleaks on staged files → OSV on dependencies → Biome on staged files.
Blocked paths include .env (templates like .env.defaults.example, .env.schema, .env.test are allowed), *.pem / *.key / *.p12 / keystores, id_rsa*, certificate dumps, *.keytab.
Gitleaks looks for crypto keys and mnemonics, API keys, JWTs, DB passwords, and cloud credentials.
pnpm security:block-files
pnpm security:secrets # staged
pnpm security:secrets:full # whole repo
pnpm security:osv
pnpm security:audit
pnpm security:check # all of the above
pnpm security:deepsec:scan # regex only, no AI
pnpm security:deepsec:process:diff # AI review vs origin/main
pnpm security:deepsec:process:diff:grok # same, Cursor Grok 4.6
pnpm security:deepsec:process # full-repo AI review (local)CI (security.yml)
Lockfile integrity (--frozen-lockfile), gitleaks, TruffleHog (filesystem + history), OSV on pnpm-lock.yaml, pnpm audit (--ignore-registry-errors so a registry timeout is not a finding). DeepSec is a separate workflow. See GitHub Actions.
DeepSec
DeepSec reviews application code. It does not replace gitleaks, TruffleHog, OSV, or pnpm audit. Workspace: .deepsec/ at the repo root (not a pnpm workspace package). Commit deepsec.config.ts, INFO.md, and generated matchers. Scan output, .env.local, and node_modules stay gitignored. Not in pre-commit or security.yml.
Agents: CI and default local process use GPT-5.6 Sol on Codex (--agent codex --model gpt-5.6-sol). Alternate: Grok 4.6 on DeepSec pi (pnpm security:deepsec:process:diff:grok). Both go through Vercel AI Gateway. Claude is not used.
Local: scan is regex-only and free. process / process:diff need AI_GATEWAY_API_KEY. Full-repo process is an operator follow-up.
CI: Trusted same-repo PRs only. Job split, secrets, and skip conditions: GitHub Actions.
If a secret lands in git
- Rotate it immediately. Editing the file is not enough.
- If not pushed:
git reset HEAD~1, remove the secret, commit again. - If pushed: history rewrite (
git-filter-repo/ BFG) and a coordinated force-push. False positives go in.gitleaks.toml— never real secrets.
API hardening
- Headers: nosniff, frame deny, CSP, HSTS in production (
apps/api/src/plugins/security.ts). - CORS:
ALLOWED_ORIGINSmust be explicit origins in production (boot fails on*or empty). Fastifyplugins/cors.tsis the source of truth — do not setAccess-Control-Allow-Origininvercel.jsonor other deployment headers. Dev/test may use*. - Rate limit: in-memory per client IP (
RATE_LIMIT_MAX/RATE_LIMIT_TIME_WINDOW). Behind a reverse proxy, setTRUST_PROXY=trueso Fastify honorsX-Forwarded-For(Fastify 5 treats numeric hop counts as fail-closed). Local-only, not shared across instances. - Validation: TypeBox on Fastify routes. Suspicious URL/user-agent patterns are logged in
apps/api/src/lib/security.ts(not blocked; rate limits handle abuse). - JWT access and refresh tokens are signed and verified with HS256 only (
@fastify/jwt). POST /ai/chatdoes not fetch client-supplied file URLs; onlydata:file parts are accepted. Remote URLs are rejected at validation.- 6-digit login codes (magic link, change email): HMAC-SHA256 with
ENCRYPTION_KEY. API keys and other high-entropy tokens: SHA-256 at rest withtimingSafeEqualon verify.
Login route rate limit (as shipped)
A tighter cap applies only to routes that start login or exchange OAuth/passkey callback codes — not every unauthenticated auth mutation.
authLoginRouteConfig wires authRouteRateLimit: in production, productionLoginRateLimitMax (10) requests per RATE_LIMIT_TIME_WINDOW; otherwise RATE_LIMIT_MAX (default 100; Vitest and E2E use 10000).
Routes under this cap:
POST /auth/magiclink/request- OAuth
GET …/authorize-urlandPOST …/exchangefor GitHub, Google, Facebook, and Twitter POST /auth/oauth/google/verify-id-tokenPOST /auth/passkey/startPOST /auth/passkey/verifyPOST /auth/passkey/exchangePOST /auth/passkey/resolve-userPOST /auth/sessions/revoke
Other controls (not the Fastify 10/min cap): magic-link verify uses authAttempts (5 per 15 minutes → TOO_MANY_ATTEMPTS); OAuth link authorize-url is Bearer-authenticated with 10 requests per user per hour in the database.
Limits are in-memory per API instance, keyed by trusted client IP (getTrustedClientIp / request.ip). Behind a reverse proxy, set TRUST_PROXY=true.
Session list, revoke, and new-device mail
GET /auth/sessions/DELETE /auth/sessions/:id: Bearer JWT only. API keys get 400USE_KEY_REVOKE.POST /auth/sessions/revoke: public (security: []), body{ token, verificationId }. Compare-and-swap consume ofsession_revoke. Idempotent 200{ ok: true }on a second click. Wrong/expired token:INVALID_TOKEN/EXPIRED_TOKEN.- Revoke CTA origin is API
WEB_APP_URL, not the requestOrigin(OAuth exchange is server-side). IfWEB_APP_URLis not allowlisted, Fastify skips the email. - Web:
/auth/session/revokeis a public proxy path (prefix match). QueryverificationId+tokencall FastifyPOST /auth/sessions/revoke. Rate-limited with other login routes (authLoginRouteConfig).
Session cookie (web)
The Next.js POST /api/auth/update-tokens adapter writes api.session only for same-origin browser requests whose access/refresh pair Fastify has validated (POST /auth/session/validate-tokens). The cookie remains JS-readable (httpOnly: false) by design — this gate closes cross-site and forged writes, not same-origin XSS.
Related
Logging
Ops logging is Pino (API/Fastify, Next server) and a console wrapper (browser). Join key is reqId. Product analytics is a separate no-op capture(), not logs.
ESM & TypeScript Strategy
ESM-first packages, dual-mode exports, TypeScript 7 for tsc, TypeScript 6 for tools that need the compiler API.