From 266f540d881a6b8b7387d3987b5d804621ef37ef Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 23:49:05 +0200 Subject: [PATCH] fix(api): make the session cookie's Secure flag overridable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on http://batch.dev.kyuno.fr/: login/signup succeeded (200/201, profile in the body) but every subsequent request 401'd. Cause: the session cookie is `secure: NODE_ENV === "production"`, and docker-compose.yml sets NODE_ENV=production regardless of whether the deployment actually has TLS in front of it. A Secure cookie is silently never sent back by the browser over plain HTTP — no error, just a cookie that never round-trips. Adds COOKIE_SECURE, independent from NODE_ENV, to override the flag per deployment. Unset (default) keeps prior behavior — secure in production. Set COOKIE_SECURE=false only for a deployment reachable over plain HTTP (no TLS yet), like this dev instance. Verified locally: docker compose up with COOKIE_SECURE=false persists and round-trips the cookie (signup -> /auth/me 200); without it, the cookie still gets Secure as before. Full pnpm --filter api test / test:bdd suites still pass (66 + 25 scenarios). Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 +++++++ apps/api/src/config/env.ts | 16 ++++++++++++++++ apps/api/src/modules/auth/auth.routes.ts | 6 ++++-- docker-compose.yml | 7 +++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 44674ad..d17785f 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,10 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # Optional — host port for the Docker review stack's single app container # (docker-compose.yml), serving both the API and the built frontend. # APP_PORT=3000 + +# Optional — only set this to false if THIS deployment is served over +# plain HTTP (no TLS in front of it). Left unset, the session cookie +# requires HTTPS (Secure attribute) as it should for a real deployment; +# over plain HTTP a Secure cookie is silently never sent back by the +# browser, so login "succeeds" but every subsequent request 401s. +# COOKIE_SECURE=false diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 80ad249..037ad71 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -32,6 +32,22 @@ const envSchema = z.object({ * the frontend instead. */ FRONTEND_DIST_DIR: z.string().optional(), + /** + * Overrides whether the session cookie gets the `Secure` attribute + * (HTTPS-only — see auth.routes.ts). Independent from NODE_ENV on + * purpose: NODE_ENV=production doesn't imply the deployment actually + * has TLS in front of it (e.g. an HTTP-only dev/staging instance), and + * a `Secure` cookie is silently never sent back by the browser over + * plain HTTP — every authenticated request 401s despite login + * succeeding, with no error to point at the cause. Unset (the default) + * falls back to NODE_ENV === "production", same as before this existed. + * Empty string counts as unset too, so `${COOKIE_SECURE:-}` in + * docker-compose.yml doesn't force it to `false` when not provided. + */ + COOKIE_SECURE: z + .string() + .optional() + .transform((value) => (value === undefined || value === "" ? undefined : value === "true")), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts index 5a97e2e..67c2440 100644 --- a/apps/api/src/modules/auth/auth.routes.ts +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -18,8 +18,10 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; /** Cookie options shared by every route that sets the session cookie. */ const cookieOptions: CookieOptions = { httpOnly: true, - // Only require HTTPS in production — local dev/CI serve over plain HTTP. - secure: env.NODE_ENV === "production", + // Defaults to requiring HTTPS in production, but overridable via + // COOKIE_SECURE — see its doc comment in config/env.ts for why this + // can't just be `NODE_ENV === "production"`. + secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production", sameSite: "lax", maxAge: SEVEN_DAYS_MS, }; diff --git a/docker-compose.yml b/docker-compose.yml index b78bd44..24b381f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,13 @@ services: # always targets Postgres's internal port (5432). DATABASE_URL: "postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:?set POSTGRES_DB in .env}?schema=public" JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} + # Unset by default (falls back to NODE_ENV === "production", i.e. + # Secure cookie required) — set COOKIE_SECURE=false in .env only if + # this deployment is reachable over plain HTTP (no TLS in front of + # it yet), otherwise the session cookie never comes back and every + # authenticated request 401s despite login succeeding. See its doc + # comment in apps/api/src/config/env.ts. + COOKIE_SECURE: ${COOKIE_SECURE:-} ports: - "${APP_PORT:-3000}:3000"