User Authentication Implementation Plan
Date: 2026-03-01 ADR: ADR-012: User Authentication Strategy Status: PROPOSED — pending review Estimated effort: 3-4 days
Overview
Implement dual-mode authentication for sv0-platform: GitHub OAuth (admin) + email magic link (client). Replaces the current REQUIRE_AUTH=false production bypass. Includes auth-route bypass in the middleware pipeline, role-based authorization on write endpoints, new MongoDB collections integrated with the storage layer, and a full frontend migration from localStorage header-based auth to cookie-based sessions.
Prerequisites
Before implementation:
-
Register GitHub OAuth App in the SecurityV0 org settings:
- Homepage URL:
https://app.securityv0.com - Callback URL:
https://app.securityv0.com/api/v1/auth/github/callback - Scopes needed:
read:org,user:email - Record
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET
- Homepage URL:
-
Set up Resend account at resend.com:
- Free tier: 3,000 emails/month (sufficient for <20 users)
- Verify sending domain or use Resend's default domain for pilot
- Record
RESEND_API_KEY
-
Generate JWT signing secret:
openssl rand -base64 64→ store asJWT_SECRETenv var- Must be the same across API server restarts (not randomly generated on boot)
Environment Variables
Add to production .env and docker-compose.yml:
# Auth (general)
REQUIRE_AUTH=true
JWT_SECRET=<base64-encoded-secret>
SESSION_COOKIE_NAME=sv0_session
SESSION_MAX_AGE_HOURS=72
# GitHub OAuth (admin)
GITHUB_CLIENT_ID=<from-github-app>
GITHUB_CLIENT_SECRET=<from-github-app>
GITHUB_ORG_SLUG=SecurityV0
GITHUB_CALLBACK_URL=https://app.securityv0.com/api/v1/auth/github/callback
# Magic Link (client)
RESEND_API_KEY=<from-resend>
RESEND_FROM_EMAIL=login@securityv0.com
MAGIC_LINK_BASE_URL=https://app.securityv0.com
MAGIC_LINK_EXPIRY_MINUTES=15
MAGIC_LINK_ALLOWED_EMAILS=alice@client.com:tenant-1,bob@client.com:tenant-1
# Admin tenant mapping
ADMIN_TENANT_MAP=lucky:sv0-admin
ADMIN_DEFAULT_TENANT_ID=sv0-admin
Phase 1: Middleware Pipeline Changes
1.1 The problem (Critical finding)
Today app.ts applies middleware in this order (lines 61-77):
systemRoutes → mounted BEFORE auth (exempt)
authMiddleware → blocks unauthenticated requests (all paths except /health, /ready)
tenantContext → resolves tenant from auth or header
requireTenant → rejects requests without tenant on /api/v1/*
rateLimiters → per-path rate limits
featureRoutes → all /api/v1/* routes
Auth routes (/api/v1/auth/*) would be blocked by authMiddleware and requireTenant before login can happen. These routes must be explicitly exempted.
1.2 Solution: Expand PUBLIC_PATHS and mount auth routes before tenant enforcement
Changes to src/api/middleware/auth.ts:
Replace the hardcoded PUBLIC_PATHS set with a pattern-based check:
const PUBLIC_PATHS = new Set(["/health", "/ready"]);
const PUBLIC_PREFIXES = ["/api/v1/auth/"];
function isPublicPath(path: string): boolean {
if (PUBLIC_PATHS.has(path)) return true;
return PUBLIC_PREFIXES.some(prefix => path.startsWith(prefix));
}
Use isPublicPath(req.path) instead of PUBLIC_PATHS.has(req.path) in the early-return guard.
Changes to src/api/middleware/tenant-context.ts:
Apply the same isPublicPath check (or import the shared helper). Auth routes do not have a tenant context — they create one.
Changes to src/api/app.ts:
Mount auth routes after authMiddleware (so they benefit from the public-path bypass) but before requireTenant:
// Existing
app.use(createSystemRoutes(deps.systemDeps));
app.use(authMiddleware({ ... }));
app.use(tenantContextMiddleware(deps.config.tenantHeader));
// NEW: auth routes — exempt from requireTenant
app.use(createAuthRoutes(deps.authDeps));
// Existing
app.use("/api/v1", requireTenant);
// ... feature routes
This means auth routes:
- Pass through
authMiddleware→ early-return on public prefix →next()with noreq.auth - Pass through
tenantContextMiddleware→ early-return on public prefix →next()with noreq.tenantId - Are mounted before
requireTenant→ never reach the tenant guard - Are mounted before rate limiters → no accidental rate-limit collisions with
/api/v1/ingest
The /api/v1/auth/me endpoint is an exception — it requires a valid session to return user info. This route is NOT in the public prefix list; it lives under /api/v1/auth/me but is authenticated (session cookie resolved by authMiddleware's cookie path).
Wait — that conflicts. Resolution: /api/v1/auth/me and /api/v1/auth/logout are not under the public prefix. Refine the public set:
const AUTH_PUBLIC_PATHS = new Set([
"/api/v1/auth/github",
"/api/v1/auth/github/callback",
"/api/v1/auth/magic-link",
"/api/v1/auth/magic-link/verify",
]);
function isPublicPath(path: string): boolean {
if (PUBLIC_PATHS.has(path)) return true;
return AUTH_PUBLIC_PATHS.has(path);
}
This is explicit and auditable — no prefix wildcards that could accidentally exempt future auth-adjacent routes.
1.3 Remove Bearer JWT path
Delete the authorization?.startsWith("Bearer ") block from authMiddleware (lines 63-88 of auth.ts). This path decodes JWTs without signature verification (G01 gap). No consumer depends on it in production. If re-added later, it must use jose with JWKS.
1.4 Add session cookie authentication path
Add a new block in authMiddleware, checked after API key and before the final 401 rejection:
const sessionCookie = req.cookies?.[SESSION_COOKIE_NAME];
if (sessionCookie) {
const payload = verifySession(sessionCookie); // jose HS256 verify
if (!payload) {
reject(res, 401, "UNAUTHORIZED", "Invalid or expired session");
return;
}
req.auth = {
method: "oauth2",
principalId: payload.sub,
tenantId: payload.tenantId,
scopes: ROLE_SCOPES[payload.role] ?? [],
};
next();
return;
}
This requires adding cookie-parser middleware in app.ts (before authMiddleware).
New npm dependency: cookie-parser (+ @types/cookie-parser).
Phase 2: Authorization Enforcement
2.1 The problem (High finding)
The plan defines roles and scopes but no route-level enforcement. Mutable endpoints exist (POST /ingest/*, PATCH /findings/:id/status). Without a guard, a client session can reach write surfaces.
2.2 Solution: requireRole middleware
New file: src/api/middleware/require-role.ts
import type { Request, Response, NextFunction } from "express";
export function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const role = extractRole(req.auth);
if (!role || !allowedRoles.includes(role)) {
res.status(403).json({
error: { code: "FORBIDDEN", message: "Insufficient permissions", status: 403 }
});
return;
}
next();
};
}
function extractRole(auth: Request["auth"]): string | undefined {
if (!auth) return undefined;
// Admin scopes include "*"; client scopes are read-only
if (auth.scopes.includes("*")) return "admin";
if (auth.scopes.includes("api:read")) return "client";
// API key callers (connector ingestion) retain their current behavior
if (auth.scopes.includes("connector:ingest")) return "admin";
return undefined;
}
2.3 Route-level application
Apply requireRole to the write surface. Changes in route files:
| File | Route | Guard |
|---|---|---|
routes/ingest.ts | POST /api/v1/ingest/normalized-graph | requireRole("admin") |
routes/ingest.ts | POST /api/v1/ingest/connector-report | requireRole("admin") |
routes/findings.ts | PATCH /api/v1/findings/:id/status | requireRole("admin") |
routes/admin.ts (new) | POST /api/v1/admin/allowed-clients | requireRole("admin") |
routes/admin.ts (new) | DELETE /api/v1/admin/allowed-clients/:email | requireRole("admin") |
routes/admin.ts (new) | GET /api/v1/admin/allowed-clients | requireRole("admin") |
All GET routes under /api/v1 remain accessible to both admin and client roles. Tenant scoping already ensures clients only see their own data.
Phase 3: Backend Auth Module
3.1 New files
src/api/auth/
├── routes.ts # createAuthRoutes() — GitHub + magic link + me + logout
├── github.ts # GitHub OAuth logic (redirect, callback, org check)
├── magic-link.ts # Magic link logic (request, verify)
├── session.ts # JWT signing/verification, cookie helpers
└── whitelist.ts # Email whitelist check (env var + MongoDB)
3.2 Session management (session.ts)
JWT payload structure:
interface SessionPayload {
sub: string; // "github:12345" or "email:user@example.com"
role: "admin" | "client";
tenantId: string;
iat: number;
exp: number;
}
Functions:
signSession(payload: Omit<SessionPayload, "iat" | "exp">): string— Sign JWT withJWT_SECRETusingjoselibrary, HS256 algorithm. SetexptoSESSION_MAX_AGE_HOURS.verifySession(token: string): SessionPayload | null— Verify signature + expiry viajose. Return null on any failure (do not throw).setSessionCookie(res: Response, token: string): void— SetHttpOnly, Secure, SameSite=Strictcookie withSESSION_MAX_AGE_HOURSmax-age.clearSessionCookie(res: Response): void— Clear the session cookie (for logout).
Cookie configuration:
{
httpOnly: true,
secure: process.env.NODE_ENV !== "development", // allow HTTP in local dev
sameSite: "lax", // "lax" not "strict" — see note below
maxAge: SESSION_MAX_AGE_HOURS * 60 * 60 * 1000,
path: "/",
}
SameSite=Lax note: The GitHub OAuth callback is a cross-site redirect from github.com back to our callback URL. With SameSite=Strict, the browser would not send the PKCE state cookie on this cross-site navigation, breaking the flow. Lax allows cookies on top-level navigations (GET redirects), which is exactly the OAuth callback pattern. The session cookie set after the callback is delivered to subsequent same-site requests normally.
3.3 GitHub OAuth flow (github.ts)
Routes (in routes.ts):
| Method | Path | Public? | Description |
|---|---|---|---|
| GET | /api/v1/auth/github | Yes | Redirect to GitHub authorize URL with PKCE |
| GET | /api/v1/auth/github/callback | Yes | Exchange code for token, verify org, issue session |
| GET | /api/v1/auth/me | No | Return session payload if authenticated |
| POST | /api/v1/auth/logout | No | Clear session cookie |
Callback logic (step by step):
- Read
statefrom query string. Readoauth_statecookie. Verify they match (CSRF protection). Clear theoauth_statecookie. - Read
code_verifierfromoauth_pkcecookie. Clear theoauth_pkcecookie. - Exchange authorization
code+code_verifierfor access token viaPOST https://github.com/login/oauth/access_token. - Fetch user profile:
GET https://api.github.com/user— extractid,login,email. - Fetch user orgs:
GET https://api.github.com/user/orgswith the access token. - Check if
GITHUB_ORG_SLUGappears in the org list (match onloginfield). - If not a member: redirect to
/login?error=not_org_member. - Determine
tenantId: look uploginin an admin-tenant mapping (env varADMIN_TENANT_MAP=github_login:tenant_id,...), fall back toADMIN_DEFAULT_TENANT_ID. - Sign session JWT with
sub: "github:<id>",role: "admin",tenantId. - Set session cookie.
- Redirect to
/(the SPA).
PKCE implementation:
- Generate
code_verifierascrypto.randomBytes(32).toString('base64url')before redirect. - Compute
code_challengeas SHA-256 of verifier, base64url-encoded. - Store
code_verifierin a short-lived HttpOnly cookie (oauth_pkce, max-age 10 minutes,SameSite=Lax). - Store
statein a separate short-lived HttpOnly cookie (oauth_state, max-age 10 minutes,SameSite=Lax). - Send
code_challenge+code_challenge_method=S256+statein the GitHub authorize URL.
Cookie settings for OAuth state/PKCE cookies: These must be SameSite=Lax (not Strict) because the GitHub callback is a cross-site redirect. The browser must send these cookies on the top-level navigation back from github.com.
No external OAuth library needed. GitHub OAuth is simple enough to implement with fetch — no passport-github2 needed. Total: ~100-150 lines.
3.4 Magic link flow (magic-link.ts)
Routes (in routes.ts):
| Method | Path | Public? | Description |
|---|---|---|---|
| POST | /api/v1/auth/magic-link | Yes | Request a magic link (body: { email }) |
| GET | /api/v1/auth/magic-link/verify | Yes | Verify token from email link (query: token) |
Request flow (POST /auth/magic-link):
- Extract
emailfrom request body. Normalize to lowercase, trim. - Apply generic rate limit first — max 5 requests per IP per 15-minute window (or use the existing
queryRateLimit). This fires before the whitelist check, so the rate-limit response is the same regardless of whether the email is whitelisted. - Check whitelist (see 3.6 below).
- Always return 200 with
{ message: "If this email is authorized, a login link has been sent." }— regardless of whitelist result. The remaining steps only execute if whitelisted. - Per-email secondary rate limit (checked in DB): max 3 active (unexpired, unconsumed) tokens per email. If exceeded, silently skip sending (still return the generic 200).
- Generate token:
crypto.randomBytes(32).toString('base64url'). - Hash:
crypto.createHash('sha256').update(token).digest('hex'). - Look up
tenantIdfor this email from whitelist record. - Insert into
magic_link_tokenscollection:{ tokenHash, email, tenantId, expiresAt: now + 15min, createdAt: now }. - Send email via Resend API:
POST https://api.resend.com/emails
{
from: RESEND_FROM_EMAIL,
to: email,
subject: "SecurityV0 — Log in",
html: "<p>Click to log in:</p><a href='{{MAGIC_LINK_BASE_URL}}/auth/verify?token={{token}}'>Log in to SecurityV0</a><p>This link expires in 15 minutes.</p>"
}
Email enumeration mitigation (Medium finding, resolved):
The rate limit in step 2 is IP-based and generic — it fires before any whitelist lookup. An attacker hitting the endpoint with different emails from the same IP gets rate-limited after 5 requests regardless of whether any email is whitelisted. The per-email secondary limit in step 5 is only checked after the whitelist passes, so it never produces a distinguishable response. No timing side-channel: the DB whitelist lookup executes on every request (even for non-whitelisted emails, it's a quick index miss), and the 200 response is returned immediately.
Verify flow (GET /auth/magic-link/verify):
- Extract
tokenfrom query string. - Hash the token.
- Find document in
magic_link_tokenswheretokenHashmatches,consumedAtis not set, andexpiresAt > now. - If not found: redirect to
/login?error=invalid_or_expired. - Atomically set
consumedAt: new Date()(usefindOneAndUpdatewithconsumedAt: { $exists: false }filter to prevent race conditions). - If atomic update matched 0 documents (already consumed): redirect to
/login?error=already_used. - Sign session JWT with
sub: "email:<email>",role: "client",tenantId(from the token record). - Set session cookie.
- Redirect to
/(the SPA).
3.5 MongoDB collections and storage integration (Medium finding)
Two new collections are needed. These must be registered in the platform's storage layer, not created ad-hoc.
Changes to src/storage/mongo/collections.ts:
Add to COLLECTION_NAMES:
magicLinkTokens: "magic_link_tokens",
allowedClients: "allowed_clients",
Add to TypedCollections interface:
magicLinkTokens: Collection<MagicLinkTokenDoc>;
allowedClients: Collection<AllowedClientDoc>;
Add to getCollections() return.
New type file: src/domain/auth/types.ts:
export interface MagicLinkTokenDoc {
tokenHash: string; // SHA-256 of the random token
email: string;
tenantId: string;
expiresAt: Date; // TTL index for auto-cleanup
consumedAt?: Date; // set on first use
createdAt: Date;
}
export interface AllowedClientDoc {
email: string; // lowercase, unique
tenantId: string;
addedAt: Date;
addedBy: string; // "github:<admin_id>" who added them
active: boolean;
}
Changes to src/storage/mongo/schema.ts:
Add to EXPECTED_INDEXES:
[COLLECTION_NAMES.magicLinkTokens]: [
"token_hash_unique", // unique index on tokenHash
"email_active_tokens", // { email: 1, consumedAt: 1, expiresAt: 1 } for rate limiting
"ttl_expiry", // TTL index on expiresAt
],
[COLLECTION_NAMES.allowedClients]: [
"email_unique", // unique index on email
"tenant_active", // { tenantId: 1, active: 1 }
],
Add index creation in the schema setup function (same pattern as existing indexes):
// magic_link_tokens
await magicLinkTokens.createIndex({ tokenHash: 1 }, { unique: true, name: "token_hash_unique" });
await magicLinkTokens.createIndex({ email: 1, consumedAt: 1, expiresAt: 1 }, { name: "email_active_tokens" });
await magicLinkTokens.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, name: "ttl_expiry" });
// allowed_clients
await allowedClients.createIndex({ email: 1 }, { unique: true, name: "email_unique" });
await allowedClients.createIndex({ tenantId: 1, active: 1 }, { name: "tenant_active" });
Changes to src/storage/storage-adapter.ts: No changes needed. The auth module accesses these collections directly through TypedCollections (same as how the posture module accesses postureSnapshots). Auth is not part of the domain query interface.
Changes to architecture docs: Update sv0-documentation/docs/architecture/03-database.md to list the two new collections in the schema section (total: 15 collections, up from 13).
3.6 Email whitelist (whitelist.ts)
Two sources, checked in order:
- Env var (
MAGIC_LINK_ALLOWED_EMAILS): Comma-separated list ofemail:tenantIdpairs. Quick bootstrap for first clients. - MongoDB collection (
allowed_clients): Documents with{ email, tenantId, active: true }.
Lookup function: isWhitelisted(email: string): Promise<{ allowed: boolean; tenantId?: string }>.
Env var entries take precedence. Every env var entry must have an explicit tenantId (no implicit defaults — that's a security decision that shouldn't be implicit).
Phase 4: Frontend Migration
4.1 The problem (High finding)
The frontend migration is not just "add LoginPage + AuthGuard." The current frontend auth model is fundamentally different from cookie-based sessions:
ui/src/context/auth-context.tsx: Stores{ tenantId, apiKey }in localStorage.isConfigured=tenantId.length > 0.ui/src/context/auth-types.ts:AuthState = { tenantId: string; apiKey: string }.ui/src/api/client.ts:apiFetch()andapiMutate()takeopts: ApiOptionswithtenantIdandapiKey, send them asx-tenant-idandx-api-keyheaders. Nocredentials: "include".- Every hook (
use-findings.ts,use-posture.ts,use-entities.ts, etc.): Destructures{ tenantId, apiKey }fromuseAuth()and passes to API client. - Every page (
OverviewPage,Dashboard,FindingsList, etc.): Checksauth.isConfiguredand renders a "configure credentials" prompt if false. ui/src/App.tsx: No route-level auth guard. All routes unconditionally inside<Layout>.ui/src/pages/SettingsPage.tsx: Manual tenant ID / API key input form — this becomes the login page replacement.
This is a broad migration, not a few new components.
4.2 Migration strategy
The migration replaces the localStorage auth model with a cookie-based session model. The API client stops sending x-tenant-id / x-api-key headers (the cookie carries the session, and the backend resolves tenant from the JWT). API-key access remains for programmatic/connector use only.
Step-by-step:
4.2.1 API client (ui/src/api/client.ts)
Replace ApiOptions with cookie-based requests:
// Before
export interface ApiOptions {
tenantId: string;
apiKey?: string;
}
// After
// No ApiOptions needed — session cookie is sent automatically
export async function apiFetch<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
credentials: "include", // sends session cookie
});
if (response.status === 401) {
// Session expired — redirect to login
window.location.href = "/login";
throw new ApiError(401, "UNAUTHORIZED", "Session expired");
}
// ... existing error handling
}
export async function apiMutate<T>(
path: string,
opts: { method: string; body?: unknown },
): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method: opts.method,
credentials: "include", // sends session cookie
headers: { "Content-Type": "application/json" },
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
// ... same 401 handling
}
4.2.2 Auth context (ui/src/context/)
Replace localStorage-based auth with session-based auth:
auth-types.ts — new:
export interface SessionUser {
sub: string;
role: "admin" | "client";
tenantId: string;
}
export interface AuthContextValue {
user: SessionUser | null;
isAuthenticated: boolean;
isLoading: boolean;
logout: () => Promise<void>;
}
auth-context.tsx — new: No localStorage. On mount, calls GET /api/v1/auth/me to check if a valid session cookie exists. Returns { user, isAuthenticated, isLoading, logout }.
use-auth.ts — updated to return the new AuthContextValue.
4.2.3 Hook migration (all hooks in ui/src/hooks/)
Every hook currently receives { tenantId, apiKey } from useAuth() and passes them to apiFetch:
// Before (every hook)
const { tenantId, apiKey } = useAuth();
return useQuery({
queryKey: ["findings", tenantId],
queryFn: () => apiFetch<FindingsResponse>("/api/v1/findings", { tenantId, apiKey }),
enabled: !!tenantId,
});
// After (every hook)
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: ["findings"],
queryFn: () => apiFetch<FindingsResponse>("/api/v1/findings"),
enabled: isAuthenticated,
});
This is a mechanical change across all hook files (~15 files). The tenantId is no longer in the query key (it's implicit in the session). The enabled guard changes from !!tenantId to isAuthenticated.
Files to change:
ui/src/hooks/use-findings.tsui/src/hooks/use-entities.tsui/src/hooks/use-posture.tsui/src/hooks/use-exposures.tsui/src/hooks/use-syncs.tsui/src/hooks/use-graph.tsui/src/hooks/use-chains.tsui/src/hooks/use-evidence.tsui/src/hooks/use-authority-paths.tsui/src/hooks/use-risk-clusters.ts- Any other
use-*.tsfiles in the hooks directory
4.2.4 Page migration (all pages in ui/src/pages/)
Remove auth.isConfigured checks from every page. The AuthGuard component (see 4.2.6) handles unauthenticated state globally — individual pages no longer need to check.
Every page that currently does:
if (!auth.isConfigured) {
return <ConfigurePrompt />;
}
Removes this block entirely. The page can assume it's authenticated (the guard already redirected otherwise).
Files to change: Every page component that checks isConfigured (~15-20 files based on the grep results).
4.2.5 New pages
ui/src/pages/LoginPage.tsx:
Two sections:
- "Log in with GitHub" button → navigates to
GET /api/v1/auth/github(full page navigation, not fetch) - Email input field + "Send login link" button → POST to
/api/v1/auth/magic-linkwith{ email }→ show "Check your email" message - Simple centered card, SecurityV0 branding, no navigation chrome
ui/src/pages/AuthVerifyPage.tsx:
Intermediate confirmation page that defeats corporate email scanners. When the user clicks the magic link in their email, they land on /auth/verify?token=... which renders:
- A page saying "Click below to complete your login"
- A "Complete login" button that navigates to
GET /api/v1/auth/magic-link/verify?token=...
The scanner will load the page but won't click the button.
4.2.6 Route protection
ui/src/components/AuthGuard.tsx:
function AuthGuard({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <LoadingSpinner />;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
ui/src/App.tsx — updated routes:
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/verify" element={<AuthVerifyPage />} />
<Route element={<AuthGuard><Layout /></AuthGuard>}>
{/* ...existing routes (unchanged)... */}
</Route>
</Routes>
4.2.7 Remove SettingsPage auth configuration
The current SettingsPage has a form for manually entering tenantId and apiKey. This is replaced by the login flow. The SettingsPage either:
- Is removed entirely (if it has no other settings)
- Has the auth configuration section removed (if it has other settings)
4.2.8 CORS configuration
The backend already has credentials: true in the CORS config (app.ts line 55). Verify that corsAllowedOrigins includes the UI origin (http://localhost:5173 for dev, https://app.securityv0.com for prod). This is required for credentials: "include" to work.
Phase 5: Admin Whitelist Management
Minimal admin tooling (not a full UI — just an API endpoint and a CLI script).
5.1 Admin API endpoint
New file: src/api/routes/admin.ts
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/admin/allowed-clients | requireRole("admin") | List whitelisted emails |
| POST | /api/v1/admin/allowed-clients | requireRole("admin") | Add email to whitelist |
| DELETE | /api/v1/admin/allowed-clients/:email | requireRole("admin") | Remove email from whitelist |
Mounted in app.ts alongside other feature routes (after requireTenant).
5.2 CLI script (bootstrap)
npx tsx scripts/add-allowed-client.ts --email alice@client.com --tenant-id client-tenant-1
For initial setup before admin UI exists.
Phase 6: Production Deployment
6.1 Deployment steps
- Add all new env vars to the production server (see Environment Variables section).
- Add
cookie-parsertopackage.jsondependencies. - Deploy new code.
- Run schema setup (the existing
ensureSchemafunction will create the new collections and indexes). - Add initial whitelist entries via CLI script or env var.
- Set
REQUIRE_AUTH=true. - Test GitHub login with a SecurityV0 org member.
- Test magic link with a whitelisted email.
- Verify API-key access still works for connectors (backward compatible).
6.2 Rollback plan
If auth breaks production access:
- Set
REQUIRE_AUTH=falseto restore the current behavior immediately. - Debug and fix.
- Re-enable.
6.3 Nginx/proxy changes
If the UI is served via nginx (current prod setup), ensure:
/api/v1/auth/*routes are proxied to the Express API (same as all/api/v1/*)/loginand/auth/verifyare handled by the SPA (client-side routes — nginx servesindex.html)- Cookie
Set-Cookieheaders pass through (no stripping by proxy) proxy_passpreserves theHostheader (required for cookie domain matching)
Security Checklist
| Concern | Implementation |
|---|---|
| Auth route bypass | Explicit path set in isPublicPath() — no wildcards, auditable |
| Token entropy | crypto.randomBytes(32) — 256 bits of entropy |
| Token storage | Store only SHA-256 hash in DB, never the raw token |
| Token replay | Atomic findOneAndUpdate with consumedAt: { $exists: false } guard |
| Token expiry | 15-minute TTL + MongoDB TTL index for cleanup |
| Email enumeration | IP-based generic rate limit fires before whitelist check; identical 200 response always; per-email limit silent |
| Rate limiting | IP-based rate limit (generic, pre-whitelist) + per-email active-token cap (post-whitelist, silent) |
| Session cookie XSS | HttpOnly: true — inaccessible to JavaScript |
| Session cookie theft | Secure: true — HTTPS only (disabled in dev) |
| Session CSRF | SameSite: Lax — allows OAuth redirects, blocks cross-site POST |
| GitHub org spoofing | Server-side org membership check using GitHub API, never trust client claims |
| OAuth CSRF | state parameter in GitHub OAuth flow, stored in Lax cookie, verified on callback |
| OAuth PKCE | code_verifier stored in Lax cookie (not Strict — cross-site callback) |
| JWT signing | HS256 via jose with 512-bit secret, signature verified on every request |
| Bearer JWT removed | Unsigned Bearer path deleted — eliminates G01 attack surface |
| Authorization | requireRole() middleware on all write endpoints; client role is read-only |
| Tenant isolation | JWT tenantId is authoritative; no header override for cookie sessions |
| Corporate email scanners | Intermediate confirmation page (scanner loads page, won't click button) |
| Logout | Cookie cleared server-side + client-side redirect |
Dependencies
npm packages to add (backend):
| Package | Purpose | Size |
|---|---|---|
jose | JWT signing/verification (HS256) | ~45KB |
resend | Email sending API client | ~15KB |
cookie-parser | Parse cookies from requests | ~8KB |
No passport, no OAuth library — GitHub OAuth is implemented with plain fetch.
npm packages to add (frontend):
None — uses existing React Router, TanStack Query, lucide-react.
Testing Plan
Backend unit tests (test/api/auth/)
session.test.ts: JWT sign/verify round-trip, expired token rejection, tampered token rejection, missing claimsgithub.test.ts: Org membership check (member/non-member), state parameter validation, PKCE code_verifier flow, non-Lax cookie rejectionmagic-link.test.ts: Token generation + hash storage, verification + atomic consumption, expired token rejection, IP-based rate limiting (generic, pre-whitelist), per-email rate limiting (silent), whitelist checkwhitelist.test.ts: Env var parsing (with tenantId), MongoDB lookup, precedence order, case normalizationrequire-role.test.ts: Admin access to write routes, client rejection from write routes, API-key caller pass-throughauth-middleware.test.ts: Public path bypass for auth routes, cookie session resolution, 401 on missing auth, API-key path unchanged
Backend integration tests (test/integration/)
- Full GitHub OAuth callback flow (mocked GitHub API responses)
- Full magic link request → verify flow with MongoDB
- Session cookie issuance and subsequent authenticated API request
- Whitelist add/remove via admin API
- Client session blocked from
POST /ingestandPATCH /findings/:id/status - API-key ingestion still works (backward compatibility)
- Schema setup creates new collections and indexes
Frontend tests (ui/src/__tests__/)
LoginPage.test.tsx: Renders both login options, email submission, success/error statesAuthGuard.test.tsx: Redirects unauthenticated users, passes through authenticated usersAuthVerifyPage.test.tsx: Renders confirmation button, navigates to verify endpointuseAuth.test.tsx: Session check on mount (GET /auth/me), logout flow, 401 redirect
Manual QA checklist
- GitHub login: SecurityV0 org member → success → lands on
/ - GitHub login: non-org GitHub user → rejection → error on
/login - Magic link: whitelisted email → receives email → clicks link → confirmation page → clicks button → logged in
- Magic link: non-whitelisted email → no email sent, same 200 response shown
- Magic link: expired token → error page
- Magic link: already-used token → error page
- Magic link: 6th request from same IP in 15 min → rate limited (same generic response)
- Session expiry: after 72h → redirected to
/login - Logout: cookie cleared, redirected to
/login - Client session: can view findings, entities, graph → yes
- Client session: cannot POST to ingest or PATCH finding status → 403
- API key auth: connector ingestion still works (no cookie needed)
- Cross-tab: open two tabs, log out in one → other tab gets 401 on next API call → redirect to login
- Dev mode:
REQUIRE_AUTH=falsestill bypasses everything (backward compatible for local dev)
Resolved Decisions
-
Admin tenant model: Admins are tenant-scoped. Each admin session carries a single
tenantIdfrom a configurable mapping. No cross-tenant browsing. Tenant-switcher (new cookie) can be added later if needed. -
Bearer JWT path: Removed for pilot. No consumer depends on it. Re-add with JWKS verification when a real integration requires it.
-
OAuth cookie SameSite:
Lax, notStrict. The GitHub callback is a cross-site top-level redirect. Strict would drop the state/PKCE cookies on return. -
Rate limit enumeration: IP-based generic rate limit fires before whitelist lookup. Per-email limit is post-whitelist and silent. No distinguishable response between whitelisted and non-whitelisted emails.
-
Storage integration: New collections registered in
COLLECTION_NAMES,TypedCollections,getCollections(),EXPECTED_INDEXES, and schema setup. Follows existing platform storage conventions.
Open Questions
-
Admin tenant switching: If an admin needs to view multiple tenants (e.g., demo different clients), should we add a
/api/v1/auth/switch-tenantendpoint that issues a new cookie? Or is re-login sufficient at <5 tenants? Current decision: re-login. Revisit if painful. -
Session refresh: Fixed 72h expiry, no sliding window. User re-authenticates after expiry. Sufficient for pilot?
-
Audit logging: Auth events (login, logout, magic link request, failed org check) should be logged. Is this a requirement for pilot, or can it follow? Current decision: log to structured logger (existing), no separate audit collection yet.
Out of Scope (Intentionally Deferred)
- SAML SSO (enterprise requirement — add when a client demands it)
- SCIM provisioning (automated user lifecycle)
- Self-service invitation flow (admin UI for whitelist management)
- MFA / TOTP (magic links are single-factor by nature; GitHub has its own 2FA)
- Cross-tenant admin browsing (requires allowed-tenant-set policy layer)
- Bearer JWT with JWKS verification (re-add when a real consumer needs it)
- Auth event audit collection (log to structured logger for now)
- Password-based auth (rejected in ADR-012)