Skip to content

Commit f3a4bc4

Browse files
authored
Apply security headers to Workers auth error responses (#74)
2 parents d09ee1d + f468db0 commit f3a4bc4

5 files changed

Lines changed: 100 additions & 15 deletions

File tree

‎SECURITY.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes,
1818

1919
- **FAIL IF** a state-changing POST is accepted without an exact `Origin` match, a signature-verified CSRF cookie, a matching `X-CSRF-Token` and an `application/json` body; inspect `protectAuth` in `better-auth-security.ts`.
2020
- **FAIL IF** an upstream Better Auth route outside the read/write allowlist answers with anything but 404, or a provider callback accepts a method other than GET and Apple's `form_post` relay; inspect `protectAuth`.
21-
- **FAIL IF** a response leaves without `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and a CSP denying framing, inline script and third-party sources; inspect `protectAuth`.
21+
- **FAIL IF** a response, including a Workers setup or teardown failure, leaves without `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and a CSP denying framing, inline script and third-party sources; inspect `authSecurityHeaders`, `protectAuth` and `createBetterAuthWorker`.
2222
- **FAIL IF** the app accepts a non-canonical origin, a secret under 32 characters, or a success/error path off the application origin; inspect `authOptions` in `better-auth.ts`.
2323

24-
Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `auth surface: an allowlisted write with a non-JSON body answers 415`, `auth options reject a non-canonical origin, a short secret and off-origin redirect paths`, `Better Auth email rejects expired codes and cross-origin sign-in`; `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state`, `Better Auth OAuth: callbacks refuse every method but GET and Apple's form-encoded POST`.
24+
Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `auth surface: an allowlisted write with a non-JSON body answers 415`, `auth options reject a non-canonical origin, a short secret and off-origin redirect paths`, `Better Auth email rejects expired codes and cross-origin sign-in`; `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state`, `Better Auth OAuth: callbacks refuse every method but GET and Apple's form-encoded POST`; `integration/better-auth-workers.test.ts`: `Workers auth setup failures return a safe 503 with security headers`.
2525

2626
## Email codes
2727

‎packages/auth/src/better-auth-security.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHmac, timingSafeEqual } from 'node:crypto';
22
import { sql } from 'kysely';
3-
import type { Hono } from 'hono';
3+
import type { Hono, MiddlewareHandler } from 'hono';
44
import { isIdentityEmail } from './better-auth-email.ts';
55
import { bodyLimit } from 'hono/body-limit';
66
import type { connectDatabase } from 'pgstencil/postgres';
@@ -131,6 +131,20 @@ async function consume(
131131
return result.rows.length === 1;
132132
}
133133

134+
/** Apply the same policy to auth responses and the Workers adapter's fallbacks. */
135+
export const authSecurityHeaders: MiddlewareHandler = async (c, next) => {
136+
await next();
137+
// Apply to the final response, including upstream immutable redirects.
138+
c.header('Cache-Control', 'no-store');
139+
c.header('Referrer-Policy', 'no-referrer');
140+
c.header('X-Content-Type-Options', 'nosniff');
141+
c.header('X-Frame-Options', 'DENY');
142+
c.header(
143+
'Content-Security-Policy',
144+
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
145+
);
146+
};
147+
134148
export function protectAuth(
135149
app: Hono,
136150
options: {
@@ -170,18 +184,7 @@ export function protectAuth(
170184
c.req.raw = new Request(c.req.raw, { headers });
171185
await next();
172186
});
173-
app.use('*', async (c, next) => {
174-
await next();
175-
// Apply to the final response, including upstream immutable redirects.
176-
c.header('Cache-Control', 'no-store');
177-
c.header('Referrer-Policy', 'no-referrer');
178-
c.header('X-Content-Type-Options', 'nosniff');
179-
c.header('X-Frame-Options', 'DENY');
180-
c.header(
181-
'Content-Security-Policy',
182-
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
183-
);
184-
});
187+
app.use('*', authSecurityHeaders);
185188
app.use('/api/auth/*', bodyLimit({ maxSize: 16 * 1024 }));
186189
app.get('/api/auth/csrf', (c) => {
187190
let value = cookies(c.req.raw).get(cookieName);

‎packages/auth/src/better-auth-workers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { Hono } from 'hono';
77
import type { EmailSender } from 'pgstencil';
88
import { createAuthApp, type AuthAppOptions } from './better-auth.ts';
9+
import { authSecurityHeaders } from './better-auth-security.ts';
910
import {
1011
oauthFromEnvironment,
1112
providers,
@@ -41,6 +42,8 @@ export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
4142
},
4243
) {
4344
const app = new Hono<{ Bindings: E }>();
45+
// Setup, teardown and diagnostics can return errors outside the inner app.
46+
app.use('*', authSecurityHeaders);
4447
app.all('*', async (c) => {
4548
const handle = async () => {
4649
if (new URL(c.env.APP_ORIGIN).protocol !== 'https:')

‎tests/integration/better-auth-workers.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,34 @@ async function login(f: Fixture, address = 'worker@example.test') {
180180
};
181181
}
182182

183+
test('Workers auth setup failures return a safe 503 with security headers', async ({
184+
onTestFinished,
185+
}) => {
186+
const bundle = await bundles[0]!;
187+
const worker = new Miniflare(
188+
convertV4MiniflareOptions({
189+
modules: true,
190+
script: bundle.outputFiles[0]!.text,
191+
compatibilityDate: '2026-09-08',
192+
compatibilityFlags: ['nodejs_compat'],
193+
bindings: { APP_ORIGIN: 'http://misconfigured.example.test' },
194+
}),
195+
);
196+
onTestFinished(() => worker.dispose());
197+
const response = await worker.dispatchFetch(origin + '/api/auth/get-session');
198+
expect(response.status).toBe(503);
199+
expect(response.headers.get('cache-control')).toBe('no-store');
200+
expect(response.headers.get('referrer-policy')).toBe('no-referrer');
201+
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
202+
expect(response.headers.get('x-frame-options')).toBe('DENY');
203+
expect(response.headers.get('content-security-policy')).toBe(
204+
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
205+
);
206+
expect(await response.json()).toEqual({
207+
message: 'Sign-in is temporarily unavailable. Please try again.',
208+
});
209+
});
210+
183211
test('Better Auth in workerd: deterministic replay, separate clocks, shared database rate limits', async ({
184212
onTestFinished,
185213
}) => {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { test, expect } from 'vitest';
2+
import { createBetterAuthWorker } from '../../packages/auth/src/better-auth-workers.ts';
3+
import type { DiagnosticRecord } from '../../packages/pgstencil/src/diagnostics.ts';
4+
5+
for (const diagnostics of [false, true])
6+
for (const failure of ['insecure origin', 'email factory'])
7+
test(`Workers auth errors keep security headers: ${failure}, diagnostics ${diagnostics}`, async () => {
8+
const records: DiagnosticRecord[] = [];
9+
const privateError = 'private-email@example.test secret-token';
10+
const app = createBetterAuthWorker({
11+
...(diagnostics
12+
? {
13+
diagnostics: {
14+
sink: (record: DiagnosticRecord) => records.push(record),
15+
},
16+
}
17+
: {}),
18+
email: () => {
19+
if (failure === 'email factory') throw new Error(privateError);
20+
return { send: async () => {} };
21+
},
22+
});
23+
const response = await app.fetch(
24+
new Request('https://auth.example.test/api/auth/get-session'),
25+
{
26+
APP_ORIGIN: `${failure === 'insecure origin' ? 'http' : 'https'}://auth.example.test`,
27+
AUTH_SECRET: 'worker-test-secret-at-least-32-characters',
28+
HYPERDRIVE: { connectionString: 'postgres://unused' },
29+
},
30+
);
31+
expect(response.status).toBe(503);
32+
expect(response.headers.get('cache-control')).toBe('no-store');
33+
expect(response.headers.get('referrer-policy')).toBe('no-referrer');
34+
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
35+
expect(response.headers.get('x-frame-options')).toBe('DENY');
36+
expect(response.headers.get('content-security-policy')).toBe(
37+
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
38+
);
39+
expect(await response.json()).toEqual({
40+
message: diagnostics
41+
? 'Temporarily unavailable. Please try again.'
42+
: 'Sign-in is temporarily unavailable. Please try again.',
43+
});
44+
if (diagnostics) {
45+
expect(response.headers.get('x-request-id')).toBeTruthy();
46+
expect(
47+
records.some((record) => record.event === 'request.failed'),
48+
).toBe(true);
49+
expect(JSON.stringify(records)).not.toContain(privateError);
50+
}
51+
});

0 commit comments

Comments
 (0)