diff --git a/apps/api/package.json b/apps/api/package.json
index e0c66daed..25f9e2c6a 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -42,6 +42,7 @@
"sanitize-html": "^2.17.5",
"signale": "^1.4.0",
"stripe": "^20.0.0",
+ "tldts": "^7.4.11",
"zod": "^3.23.8"
},
"devDependencies": {
diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts
index c6b7b6683..85166c87b 100644
--- a/apps/api/src/controllers/Auth.ts
+++ b/apps/api/src/controllers/Auth.ts
@@ -55,11 +55,7 @@ export class Auth {
await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);
const token = jwt.sign(user.id);
- const cookie = UserService.cookieOptions();
-
- return res
- .cookie(UserService.COOKIE_NAME, token, cookie)
- .json({success: true, data: {id: user.id, email: user.email}});
+ return UserService.setAuthCookie(res, token).json({success: true, data: {id: user.id, email: user.email}});
}
@Post('signup')
@@ -148,9 +144,7 @@ export class Auth {
}
const token = jwt.sign(created_user.id);
- const cookie = UserService.cookieOptions();
-
- return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
+ return UserService.setAuthCookie(res, token).json({
success: true,
data: {id: created_user.id, email: created_user.email},
});
@@ -158,8 +152,7 @@ export class Auth {
@Get('logout')
public logout(req: Request, res: Response) {
- res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
- return res.json(true);
+ return UserService.clearAuthCookie(res).json(true);
}
@Get('oauth-config')
diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts
index 74f440688..f15b84206 100644
--- a/apps/api/src/controllers/Oauth/Github.ts
+++ b/apps/api/src/controllers/Oauth/Github.ts
@@ -108,8 +108,6 @@ export class Github {
}
const token = jwt.sign(user.id);
- const cookie = UserService.cookieOptions();
-
- res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
+ UserService.setAuthCookie(res, token).redirect(DASHBOARD_URI);
}
}
diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts
index 7c070f66f..e84cef01c 100644
--- a/apps/api/src/controllers/Oauth/Google.ts
+++ b/apps/api/src/controllers/Oauth/Google.ts
@@ -98,8 +98,6 @@ export class Google {
}
const token = jwt.sign(user.id);
- const cookie = UserService.cookieOptions();
-
- res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
+ UserService.setAuthCookie(res, token).redirect(DASHBOARD_URI);
}
}
diff --git a/apps/api/src/controllers/__tests__/Auth.cookie.test.ts b/apps/api/src/controllers/__tests__/Auth.cookie.test.ts
new file mode 100644
index 000000000..900a7a42a
--- /dev/null
+++ b/apps/api/src/controllers/__tests__/Auth.cookie.test.ts
@@ -0,0 +1,78 @@
+import type {NextFunction, Request, Response} from 'express';
+import express from 'express';
+import request from 'supertest';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import {factories} from '../../../../../test/helpers/index.js';
+import {NtfyService} from '../../services/NtfyService.js';
+import {Auth} from '../Auth.js';
+
+vi.mock('../../app/constants.js', async importOriginal => ({
+ ...(await importOriginal()),
+ DISABLE_SIGNUPS: false,
+ PLUNK_ENABLED: false,
+ VERIFY_EMAIL_ON_SIGNUP: false,
+}));
+
+function createAuthApp() {
+ const app = express();
+ const auth = new Auth();
+
+ app.use(express.json());
+ app.post('/auth/login', (req, res, next) => void auth.login(req, res, next));
+ app.post('/auth/signup', (req, res, next) => void auth.signup(req, res, next));
+ app.get('/auth/logout', (req, res) => auth.logout(req, res));
+ app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
+ res.status(500).json({success: false, error: error.message});
+ });
+
+ return app;
+}
+
+function currentCookie(response: request.Response): string {
+ const header = response.headers['set-cookie'];
+ if (!header) return '';
+ const cookies = Array.isArray(header) ? header : [header];
+ return cookies.at(-1) ?? '';
+}
+
+describe('authentication response cookies', () => {
+ beforeEach(() => {
+ vi.spyOn(NtfyService, 'notifyUserSignup').mockResolvedValue(undefined);
+ });
+
+ it('sets a host-only cookie on signup', async () => {
+ const response = await request(createAuthApp()).post('/auth/signup').send({
+ email: 'cookie-signup@example.com',
+ password: 'password123',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(currentCookie(response)).toContain('next_token=');
+ expect(currentCookie(response)).not.toContain('Domain=');
+ });
+
+ it('sets a host-only cookie on login', async () => {
+ await factories.createUser({email: 'cookie-login@example.com', password: 'password123'});
+
+ const response = await request(createAuthApp()).post('/auth/login').send({
+ email: 'cookie-login@example.com',
+ password: 'password123',
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(currentCookie(response)).toContain('next_token=');
+ expect(currentCookie(response)).not.toContain('Domain=');
+ });
+
+ it('expires a host-only cookie on logout', async () => {
+ const response = await request(createAuthApp()).get('/auth/logout');
+
+ expect(response.status).toBe(200);
+ expect(currentCookie(response)).toContain('next_token=;');
+ expect(currentCookie(response)).toContain('Expires=');
+ expect(currentCookie(response)).not.toContain('Domain=');
+ });
+});
diff --git a/apps/api/src/services/UserService.ts b/apps/api/src/services/UserService.ts
index 130083978..77c1d5fe4 100644
--- a/apps/api/src/services/UserService.ts
+++ b/apps/api/src/services/UserService.ts
@@ -1,18 +1,18 @@
import dayjs from 'dayjs';
+import type {Response} from 'express';
+import {getDomain} from 'tldts';
-import {API_URI, NODE_ENV} from '../app/constants.js';
+import {API_URI, DASHBOARD_URI, NODE_ENV} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
- * Extract base domain from URL for cookie sharing across subdomains
- * e.g., "http://api.example.com" -> ".example.com"
- * e.g., "http://api.localhost" -> ".localhost"
- * e.g., "http://app.plunk.local" -> ".plunk.local"
+ * Reproduce the domain scope used before host-only cookies. This is used only
+ * to expire an existing legacy cookie during login/logout migration.
*/
-function getCookieDomain(): string | undefined {
+function getLegacyCookieDomain(): string | undefined {
if (NODE_ENV === 'development') {
return undefined;
}
@@ -26,18 +26,15 @@ function getCookieDomain(): string | undefined {
return undefined;
}
- // Extract base domain (last two parts for most domains, or .localhost)
+ // This intentionally matches the old last-two-label algorithm exactly.
const parts = hostname.split('.');
if (parts.length >= 2) {
- // For *.localhost, use .localhost (reserved TLD)
if (hostname.endsWith('.localhost')) {
return '.localhost';
}
- // For *.local (mDNS TLD), use the actual base domain
if (hostname.endsWith('.local')) {
return `.${parts.slice(-2).join('.')}`;
}
- // For other domains, use the last two parts (e.g., .example.com)
return `.${parts.slice(-2).join('.')}`;
}
@@ -47,6 +44,28 @@ function getCookieDomain(): string | undefined {
}
}
+function getSchemefulSite(uri: string): string | undefined {
+ try {
+ const url = new URL(uri);
+ const registrableDomain = getDomain(url.hostname, {allowPrivateDomains: true});
+
+ return `${url.protocol}//${registrableDomain ?? url.hostname}`;
+ } catch {
+ return undefined;
+ }
+}
+
+export function getCookieSameSite(apiUri: string, dashboardUri: string, secure: boolean): 'lax' | 'none' {
+ if (!secure) {
+ return 'lax';
+ }
+
+ const apiSite = getSchemefulSite(apiUri);
+ const dashboardSite = getSchemefulSite(dashboardUri);
+
+ return apiSite !== undefined && apiSite === dashboardSite ? 'lax' : 'none';
+}
+
export class UserService {
public static readonly COOKIE_NAME = 'next_token';
@@ -95,9 +114,29 @@ export class UserService {
httpOnly: true,
expires: expires ?? dayjs().add(7, 'days').toDate(),
secure: isHttps,
- sameSite: isHttps ? 'none' : 'lax',
+ sameSite: getCookieSameSite(API_URI, DASHBOARD_URI, isHttps),
path: '/',
- domain: getCookieDomain(),
} as const;
}
+
+ private static clearLegacyCookie(res: Response) {
+ const domain = getLegacyCookieDomain();
+
+ if (domain) {
+ res.cookie(UserService.COOKIE_NAME, '', {
+ ...UserService.cookieOptions(new Date()),
+ domain,
+ });
+ }
+ }
+
+ public static setAuthCookie(res: Response, token: string) {
+ UserService.clearLegacyCookie(res);
+ return res.cookie(UserService.COOKIE_NAME, token, UserService.cookieOptions());
+ }
+
+ public static clearAuthCookie(res: Response) {
+ UserService.clearLegacyCookie(res);
+ return res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
+ }
}
diff --git a/apps/api/src/services/__tests__/UserService.cookie.test.ts b/apps/api/src/services/__tests__/UserService.cookie.test.ts
new file mode 100644
index 000000000..b844a2631
--- /dev/null
+++ b/apps/api/src/services/__tests__/UserService.cookie.test.ts
@@ -0,0 +1,75 @@
+import express from 'express';
+import request from 'supertest';
+import {describe, expect, it, vi} from 'vitest';
+
+vi.mock('../../app/constants.js', () => ({
+ API_URI: 'https://api.example.com',
+ DASHBOARD_URI: 'https://app.example.com',
+ NODE_ENV: 'production',
+}));
+
+vi.mock('../../database/prisma.js', () => ({prisma: {}}));
+vi.mock('../../database/redis.js', () => ({wrapRedis: vi.fn()}));
+
+import {getCookieSameSite, UserService} from '../UserService.js';
+
+function createCookieApp(action: 'clear' | 'set') {
+ const app = express();
+
+ app.get('/', (_req, res) => {
+ if (action === 'set') {
+ UserService.setAuthCookie(res, 'signed-token').json(true);
+ } else {
+ UserService.clearAuthCookie(res).json(true);
+ }
+ });
+
+ return app;
+}
+
+function setCookieHeaders(response: request.Response): string[] {
+ const header = response.headers['set-cookie'];
+ if (!header) return [];
+ return Array.isArray(header) ? header : [header];
+}
+
+describe('authentication cookie scope', () => {
+ it('uses Public Suffix List semantics for same-site decisions', () => {
+ expect(getCookieSameSite('https://api.example.co.uk', 'https://app.example.co.uk', true)).toBe('lax');
+ expect(
+ getCookieSameSite(
+ 'https://plunk-api-production-7c42.up.railway.app',
+ 'https://plunk-dashboard-production-e2e5.up.railway.app',
+ true,
+ ),
+ ).toBe('none');
+ expect(getCookieSameSite('http://api.example.com', 'http://app.example.com', false)).toBe('lax');
+ });
+
+ it('expires the legacy domain cookie before setting a host-only cookie', async () => {
+ const response = await request(createCookieApp('set')).get('/');
+ const cookies = setCookieHeaders(response);
+
+ expect(cookies).toHaveLength(2);
+ expect(cookies[0]).toContain('next_token=;');
+ expect(cookies[0]).toContain('Domain=.example.com');
+ expect(cookies[0]).toContain('Expires=');
+ expect(cookies[1]).toContain('next_token=signed-token;');
+ expect(cookies[1]).not.toContain('Domain=');
+ expect(cookies[1]).toContain('Path=/');
+ expect(cookies[1]).toContain('HttpOnly');
+ expect(cookies[1]).toContain('Secure');
+ expect(cookies[1]).toContain('SameSite=Lax');
+ });
+
+ it('expires both the legacy domain cookie and the current host-only cookie on logout', async () => {
+ const response = await request(createCookieApp('clear')).get('/');
+ const cookies = setCookieHeaders(response);
+
+ expect(cookies).toHaveLength(2);
+ expect(cookies[0]).toContain('Domain=.example.com');
+ expect(cookies[0]).toContain('Expires=');
+ expect(cookies[1]).not.toContain('Domain=');
+ expect(cookies[1]).toContain('Expires=');
+ });
+});
diff --git a/apps/landing/src/pages/privacy.tsx b/apps/landing/src/pages/privacy.tsx
index 4e4a393cc..38cacc78c 100644
--- a/apps/landing/src/pages/privacy.tsx
+++ b/apps/landing/src/pages/privacy.tsx
@@ -304,8 +304,8 @@ export default function PrivacyPolicy() {
- Purpose: Store JWT token for dashboard login
- Duration: 7 days
- - Type: First-party, httpOnly, secure (HTTPS only)
- - Scope: *.useplunk.com
+ - Type: Essential, httpOnly, secure (HTTPS only)
+ - Scope: API host only
- Can be deleted: Yes (logout clears cookie)
@@ -469,7 +469,7 @@ export default function PrivacyPolicy() {
- Password hashing: Industry-standard secure hashing
- - Cookies: httpOnly, secure, SameSite=none (HTTPS)
+ - Cookies: httpOnly, secure, SameSite=Lax for same-site deployments
- Database: TLS/SSL encrypted connections
- API: HTTPS-only (TLS 1.2+)
- JWT tokens: 7-day expiration, httpOnly cookies
@@ -883,7 +883,7 @@ export default function PrivacyPolicy() {
Duration: 7 days
-
- Domain: *.useplunk.com
+ Domain: API host only
-
Data stored: Encrypted JWT with user
diff --git a/yarn.lock b/yarn.lock
index 68c204239..06dafe385 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8271,6 +8271,7 @@ __metadata:
sanitize-html: "npm:^2.17.5"
signale: "npm:^1.4.0"
stripe: "npm:^20.0.0"
+ tldts: "npm:^7.4.11"
tsx: "npm:^4.20.6"
zod: "npm:^3.23.8"
languageName: unknown
@@ -19019,6 +19020,24 @@ __metadata:
languageName: node
linkType: hard
+"tldts-core@npm:^7.4.11":
+ version: 7.4.11
+ resolution: "tldts-core@npm:7.4.11"
+ checksum: 10c0/df144de6f97458a88146897cdbfb3059a3ddc124f6ceb20c9bae71a6c7c41b553ea83b6791726b2387b10babf1d714f8c95b672e9867bfe146f9788f4811749e
+ languageName: node
+ linkType: hard
+
+"tldts@npm:^7.4.11":
+ version: 7.4.11
+ resolution: "tldts@npm:7.4.11"
+ dependencies:
+ tldts-core: "npm:^7.4.11"
+ bin:
+ tldts: bin/cli.js
+ checksum: 10c0/c1cf432c4cae47bb6473fc7cbd8b7df0fb98c18b7bcd3fc138ea45a71854705c681a4c998e10b82cc014404693b0b2cd5c23c4c76d6978390a46c604ca0e5398
+ languageName: node
+ linkType: hard
+
"to-regex-range@npm:^5.0.1":
version: 5.0.1
resolution: "to-regex-range@npm:5.0.1"