From 23bd0f0c6a42926126a3693413304997edadafb0 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 08:31:12 -0400 Subject: [PATCH] fix(deploy): auth/CSRF cookies dropped on plain-HTTP prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cookie was flagged Secure whenever NODE_ENV=production. Over plain HTTP (single-host compose deploy without TLS) browsers silently discard Secure cookies, so the access token, refresh token, and CSRF cookie all vanished after login — producing 401 Unauthorized on every GET and 403 "CSRF token missing or invalid" on every mutation. Add COOKIE_SECURE to ApiEnv: optional boolean, falls back to NODE_ENV === 'production' when unset. Controllers and middleware now read env.COOKIE_SECURE instead of the NODE_ENV shortcut. The compose file sets it to false by default with a comment to flip once TLS is in front; HTTPS deployments can override via .env or drop the override to pick up the secure default. --- apps/api/src/controllers/auth.ts | 4 ++-- apps/api/src/env.ts | 5 ++++- apps/api/src/middleware/csrf.ts | 2 +- docker-compose.yml | 3 +++ packages/shared/src/env.ts | 5 +++++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index c068aa5..909e323 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -9,7 +9,7 @@ import { errors } from '../lib/http-error.js'; const accessCookieOpts: CookieOptions = { httpOnly: true, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/', maxAge: authService.ACCESS_TOKEN_TTL_MS, }; @@ -17,7 +17,7 @@ const accessCookieOpts: CookieOptions = { const refreshCookieOpts: CookieOptions = { httpOnly: true, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/api/auth', maxAge: authService.REFRESH_TOKEN_TTL_MS, }; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f67790d..0e3656d 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -11,4 +11,7 @@ if (!parsed.success) { process.exit(1); } -export const env = parsed.data; +export const env = { + ...parsed.data, + COOKIE_SECURE: parsed.data.COOKIE_SECURE ?? parsed.data.NODE_ENV === 'production', +}; diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index a559fc3..6be29a7 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -12,7 +12,7 @@ export function issueCsrfToken(res: Response): string { const opts: CookieOptions = { httpOnly: false, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/', }; res.cookie(CSRF_COOKIE, token, opts); diff --git a/docker-compose.yml b/docker-compose.yml index ea44ac7..7b5a918 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: DATABASE_URL: file:/data/vector.db JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — see .env.example} CLIENT_ORIGIN: ${CLIENT_ORIGIN:-http://localhost:8080} + # Browsers drop Secure cookies over plain HTTP. Flip to "true" once + # this deployment sits behind TLS (reverse proxy, Cloudflare, etc). + COOKIE_SECURE: ${COOKIE_SECURE:-false} volumes: - vector-data:/data healthcheck: diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 6176bab..7d79808 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -13,5 +13,10 @@ export const ApiEnv = z.object({ message: 'JWT_SECRET still matches the default placeholder — generate a real secret', }), CLIENT_ORIGIN: z.string().url().default('http://localhost:5173'), + // Whether to mark auth + CSRF cookies Secure. Must be false for plain-HTTP + // deployments (browsers silently drop Secure cookies over http://). Leave + // unset to fall back to NODE_ENV === 'production'. + COOKIE_SECURE: z + .preprocess((v) => (typeof v === 'string' ? v === 'true' : v), z.boolean().optional()), }); export type ApiEnv = z.infer;