fix: mark session cookie as Secure when served over HTTPS

Self-hosted better-auth is configured with secure cookies off so plain
HTTP access (http://<ip>:3000) keeps working, but that also meant the
session cookie never got the Secure attribute even behind HTTPS. A cookie
without Secure can be replayed over plain HTTP.

Add Secure to the auth response cookies per request when X-Forwarded-Proto
is https (what Traefik sends on TLS), leaving plain-HTTP requests untouched.

Fixes #4709
This commit is contained in:
Souvik Kumar 2026-08-09 21:05:37 +05:30
parent ef0272a4ec
commit f5c21db1ef
3 changed files with 95 additions and 1 deletions

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});

View File

@ -0,0 +1,25 @@
export const isHttpsRequest = (
forwardedProto: string | string[] | undefined,
): boolean => {
if (!forwardedProto) return false;
const value = Array.isArray(forwardedProto)
? forwardedProto[0]
: forwardedProto;
// take the first value in case a proxy chain sent a list
return value?.split(",")[0]?.trim().toLowerCase() === "https";
};
const hasSecureAttribute = (cookie: string): boolean =>
/;\s*secure(?:\s*;|\s*$)/i.test(cookie);
const withSecure = (cookie: string): string =>
hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`;
// Add Secure to a Set-Cookie header value (string or string[]).
export const secureSetCookie = (
value: string | number | readonly string[],
): string | number | string[] => {
if (typeof value === "number") return value;
if (Array.isArray(value)) return value.map(withSecure);
return withSecure(value as string);
};

View File

@ -1,7 +1,30 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { auth } from "@dokploy/server/index";
import { toNodeHandler } from "better-auth/node";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
// Disallow body parsing, we will parse it manually
export const config = { api: { bodyParser: false } };
export default toNodeHandler(auth.handler);
const handler = toNodeHandler(auth.handler);
// Mark the session cookie as Secure when the request comes over HTTPS
export default function authHandler(req: IncomingMessage, res: ServerResponse) {
if (isHttpsRequest(req.headers["x-forwarded-proto"])) {
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? setHeader(name, secureSetCookie(value))
: setHeader(name, value);
const appendHeader = res.appendHeader?.bind(res);
if (appendHeader) {
res.appendHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? appendHeader(name, secureSetCookie(value) as string | string[])
: appendHeader(name, value);
}
}
return handler(req, res);
}