fix(auth): self-hoster opt-out for session-cookie Secure flag (v1.5.1)

v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.

New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true"  — always set (pnpm dev fronted by HTTPS)

Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.

OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.

Default behaviour unchanged for HTTPS-fronted deployments.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-26 19:16:44 +02:00
1 parent 1cd28acebc
commit c6d2b212f2
11 files changed
+161 -8

No files matched your search

+10
View File
@@ -40,6 +40,16 @@ APP_URL="http://localhost:3000"
# Node environment: "development" for `pnpm dev`, "production" for Docker.
NODE_ENV="development"
# Session cookie `Secure` flag (v1.5.1).
# Defaults to NODE_ENV === "production" — the safe choice for any
# deployment terminated by HTTPS (Caddy / Traefik / Nginx in front).
# Set to `false` if you intentionally serve plain HTTP on a LAN, VPN,
# or Tailscale-only surface — the browser otherwise drops the session
# cookie on HTTP origins and login round-trips fail silently. NEVER
# set to `false` for a deployment that serves plain HTTP to the open
# internet; the session cookie crosses the wire in cleartext.
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [1.5.1] — 2026-05-26 — Self-hosting opt-out for the session-cookie `Secure` flag
A self-hoster running HealthLog on a LAN address (`http://10.x.x.x:3000`) over plain HTTP reported a silent login failure: the `/api/auth/login` POST returned 200 with a `Set-Cookie` header on the response, but the very next `/api/auth/me` request came back 401 and the page reloaded to the login screen. Root cause is the modern browser behaviour around `Secure`-flagged cookies — every cookie the app issues in `NODE_ENV=production` carries `Secure`, and on a plain-HTTP origin that is not `localhost` / `127.0.0.1` / `::1`, the browser silently drops the cookie before sending the next request. The default-Docker image runs `NODE_ENV=production`, so any operator browsing from a different host than the one running `docker compose up` (NAS / homelab / VPS / Tailscale + Magic-DNS) used to hit the dead-end.
### Added
- `SESSION_COOKIE_SECURE` environment variable. Controls the `Secure` flag on the session cookie, the onboarding-hint cookie, the Withings OAuth state cookie, and the Codex device-OAuth state cookie. Three values:
- **unset** (default) — flag is set when `NODE_ENV === "production"`, the long-standing behaviour.
- **`false`** — flag is never set. Use this for LAN / VPN / Tailscale-only deployments where the operator deliberately serves plain HTTP and accepts the trade-off (the session cookie crosses the wire unencrypted; do NOT use on an open-internet HTTP origin).
- **`true`** — flag is always set, useful when a developer fronts a `pnpm dev` server with HTTPS to test the production cookie path.
- New shared helper `src/lib/auth/secure-cookie.ts` (`shouldEmitSecureCookie()`) that every `Secure`-bearing cookie call now reads. Replaces four scattered `secure: process.env.NODE_ENV === "production"` literals in `src/lib/auth/session.ts`, `src/app/api/auth/codex/device-start/route.ts`, and `src/app/api/withings/connect/route.ts`.
- `.env.example` block documenting the new variable with a clear warning about the open-internet-HTTP case.
### Tests
- `src/lib/auth/__tests__/secure-cookie.test.ts` — six cases covering the default path, both explicit overrides, whitespace / case-insensitive parsing, and the fall-through for unrecognised values.
### Notes
- The default behaviour is unchanged for every deployment that runs behind an HTTPS-terminating reverse proxy (the documented self-hosting path) — those continue to set `Secure` exactly as before. The opt-out is intentional and explicit.
- Operators currently working around the issue with `NODE_ENV=development` can move back to `NODE_ENV=production` plus `SESSION_COOKIE_SECURE=false` on this release; that keeps build optimisation + production error masking on while letting the cookie reach the browser over HTTP.
## [1.5.0] — 2026-05-24 — Native iOS client public-beta + per-day cumulative stats overwrite + cadence-aware medication compliance
The minor-version cut that marks the native iOS client publicly available. The SwiftUI iOS app (separate repository) is now joinable via TestFlight: https://testflight.apple.com/join/bucuTBpa. The backend contract the iOS app speaks against has been live since v1.4.23 and has been continuously validated across every v1.4.2x–v1.4.50 release. The 1.5.0 cut also lands the highest-leverage iOS-client unblocker per the v0.6.1 code audit: `/api/measurements/batch` now overwrites per-day cumulative `stats:*` rows on a re-post instead of dropping the new value as a duplicate.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.5.0
version: 1.5.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+34
View File
@@ -218,3 +218,37 @@ than the actual proxy hop count, or the proxy is dropping the
The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
`NODE_ENV=production`, which is what the bundled Docker image runs.
On a plain-HTTP origin that is not `localhost` / `127.0.0.1` / `::1`,
modern browsers silently drop a `Secure` cookie before sending the
next request — the login round-trip looks like it succeeds (the
server returns 200 + `Set-Cookie`) but the very next page load
401s and bounces back to `/auth/login`.
If you genuinely want to serve HealthLog on `http://10.0.0.42:3000`
or `http://healthlog.lan:3000` (NAS, homelab, Tailscale-only
surface) and trust the network the traffic crosses, set:
```
SESSION_COOKIE_SECURE=false
```
in your `.env`. The session, onboarding-hint, Withings OAuth state,
and Codex device-OAuth state cookies all stop emitting `Secure` and
login works over HTTP. The trade-off is explicit: the session
cookie crosses the wire unencrypted, so the network it traverses
needs to be one you control.
**Do not set this on a deployment that ever serves plain HTTP to the
open internet.** Anyone with passive network access can capture the
session cookie and impersonate the user. The right answer for any
public-facing host is the reverse proxy + Let's Encrypt path
documented above.
The default behaviour is unchanged when this variable is unset —
deployments behind an HTTPS-terminating proxy keep setting `Secure`
exactly as before.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.5.0",
"version": "1.5.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+2 -1
View File
@@ -6,6 +6,7 @@ import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { apiSuccess } from "@/lib/api-response";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* v1.4.7.1: Device-code flow for ChatGPT-OAuth.
@@ -42,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
),
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
path: "/",
maxAge: 15 * 60,
+2 -1
View File
@@ -11,6 +11,7 @@ import {
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -88,7 +89,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
const response = NextResponse.redirect(url);
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
path: "/",
@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { shouldEmitSecureCookie } from "../secure-cookie";
describe("shouldEmitSecureCookie", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("emits Secure under NODE_ENV=production when no override is set", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("omits Secure under NODE_ENV=development when no override is set", () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(shouldEmitSecureCookie()).toBe(false);
});
it("omits Secure when SESSION_COOKIE_SECURE=false even under NODE_ENV=production (LAN / VPN self-host opt-out)", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
expect(shouldEmitSecureCookie()).toBe(false);
});
it("emits Secure when SESSION_COOKIE_SECURE=true even under NODE_ENV=development (dev box behind HTTPS)", () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("trims whitespace and accepts case-insensitive `False` / `TRUE`", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", " False ");
expect(shouldEmitSecureCookie()).toBe(false);
vi.stubEnv("SESSION_COOKIE_SECURE", "TRUE");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("falls back to the NODE_ENV default for unrecognised override values", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "yes");
expect(shouldEmitSecureCookie()).toBe(true);
vi.stubEnv("NODE_ENV", "development");
expect(shouldEmitSecureCookie()).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Should the `Secure` flag be set on session-class cookies?
*
* Default — the flag is on whenever `NODE_ENV === "production"`, which
* is the safe choice for any deployment terminated by HTTPS (the
* documented self-hosting path: Caddy / Traefik / Nginx in front).
*
* Opt-out — operators running plain HTTP on a LAN or private VPN can
* set `SESSION_COOKIE_SECURE=false` to drop the flag. Without the flag
* a browser will send the cookie back over HTTP, so login round-trips
* complete on `http://10.x.x.x:3000` and friends. This is intentional
* for evaluation, NAS / homelab deployments, and Tailscale-only
* surfaces; it is NOT appropriate for a deployment that ever serves
* plain HTTP to the open internet, because the session cookie then
* crosses the wire in cleartext.
*
* Opt-in — `SESSION_COOKIE_SECURE=true` forces the flag on even under
* `NODE_ENV !== "production"`, useful when a developer fronts their
* `pnpm dev` server with HTTPS for testing the production cookie path.
*
* The function is intentionally stateless / re-evaluated per call so a
* runtime env change (e.g. via `compose.yml` edit + `docker compose up
* -d`) takes effect on the next request without an app restart.
*/
export function shouldEmitSecureCookie(): boolean {
const override = process.env.SESSION_COOKIE_SECURE?.trim().toLowerCase();
if (override === "false") return false;
if (override === "true") return true;
return process.env.NODE_ENV === "production";
}
+4 -3
View File
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -28,7 +29,7 @@ export async function setOnboardingPendingCookie(
if (pending) {
cookieStore.set(ONBOARDING_COOKIE, "pending", {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
// v1.4.22 W5 reconcile (Sec-MED-1) — Strict (not Lax) because
// no cross-site redirect flow ever depends on this cookie. The
// sibling `healthlog_session` cookie stays Lax because the
@@ -70,7 +71,7 @@ export async function createSession(
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect.
// Lax keeps CSRF protection for unsafe methods while allowing this flow.
sameSite: "lax",
@@ -133,7 +134,7 @@ export async function getSession(): Promise<{
});
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: SESSION_MAX_AGE_MS / 1000,
path: "/",
+6 -1
View File
@@ -25,6 +25,7 @@
import { createDocument, type ZodOpenApiObject } from "zod-openapi";
import { openApiPaths, openApiComponents } from "./routes";
import { version as packageVersion } from "../../../package.json";
const openApiBase: Pick<
ZodOpenApiObject,
@@ -33,7 +34,11 @@ const openApiBase: Pick<
openapi: "3.1.0",
info: {
title: "HealthLog API",
version: "1.4.23",
// v1.5.1 — pulled from package.json so the spec's info.version
// tracks the release line automatically. The CI drift gate
// (`pnpm openapi:check`) catches the case where the registry
// and the committed spec disagree.
version: packageVersion,
description:
"Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.\n\n" +
"## Date-time contract\n\n" +