Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes,

- **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`.
- **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`.
- **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`.
- **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`.
- **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`.

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`.
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`.

## Email codes

Expand Down
29 changes: 16 additions & 13 deletions packages/auth/src/better-auth-security.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { sql } from 'kysely';
import type { Hono } from 'hono';
import type { Hono, MiddlewareHandler } from 'hono';
import { isIdentityEmail } from './better-auth-email.ts';
import { bodyLimit } from 'hono/body-limit';
import type { connectDatabase } from 'pgstencil/postgres';
Expand Down Expand Up @@ -131,6 +131,20 @@ async function consume(
return result.rows.length === 1;
}

/** Apply the same policy to auth responses and the Workers adapter's fallbacks. */
export const authSecurityHeaders: MiddlewareHandler = async (c, next) => {
await next();
// Apply to the final response, including upstream immutable redirects.
c.header('Cache-Control', 'no-store');
c.header('Referrer-Policy', 'no-referrer');
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
);
};

export function protectAuth(
app: Hono,
options: {
Expand Down Expand Up @@ -170,18 +184,7 @@ export function protectAuth(
c.req.raw = new Request(c.req.raw, { headers });
await next();
});
app.use('*', async (c, next) => {
await next();
// Apply to the final response, including upstream immutable redirects.
c.header('Cache-Control', 'no-store');
c.header('Referrer-Policy', 'no-referrer');
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
);
});
app.use('*', authSecurityHeaders);
app.use('/api/auth/*', bodyLimit({ maxSize: 16 * 1024 }));
app.get('/api/auth/csrf', (c) => {
let value = cookies(c.req.raw).get(cookieName);
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/better-auth-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { Hono } from 'hono';
import type { EmailSender } from 'pgstencil';
import { createAuthApp, type AuthAppOptions } from './better-auth.ts';
import { authSecurityHeaders } from './better-auth-security.ts';
import {
oauthFromEnvironment,
providers,
Expand Down Expand Up @@ -41,6 +42,8 @@ export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
},
) {
const app = new Hono<{ Bindings: E }>();
// Setup, teardown and diagnostics can return errors outside the inner app.
app.use('*', authSecurityHeaders);
app.all('*', async (c) => {
const handle = async () => {
if (new URL(c.env.APP_ORIGIN).protocol !== 'https:')
Expand Down
28 changes: 28 additions & 0 deletions tests/integration/better-auth-workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@ async function login(f: Fixture, address = 'worker@example.test') {
};
}

test('Workers auth setup failures return a safe 503 with security headers', async ({
onTestFinished,
}) => {
const bundle = await bundles[0]!;
const worker = new Miniflare(
convertV4MiniflareOptions({
modules: true,
script: bundle.outputFiles[0]!.text,
compatibilityDate: '2026-09-08',
compatibilityFlags: ['nodejs_compat'],
bindings: { APP_ORIGIN: 'http://misconfigured.example.test' },
}),
);
onTestFinished(() => worker.dispose());
const response = await worker.dispatchFetch(origin + '/api/auth/get-session');
expect(response.status).toBe(503);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('referrer-policy')).toBe('no-referrer');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(response.headers.get('x-frame-options')).toBe('DENY');
expect(response.headers.get('content-security-policy')).toBe(
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
);
expect(await response.json()).toEqual({
message: 'Sign-in is temporarily unavailable. Please try again.',
});
});

test('Better Auth in workerd: deterministic replay, separate clocks, shared database rate limits', async ({
onTestFinished,
}) => {
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/better-auth-workers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { test, expect } from 'vitest';
import { createBetterAuthWorker } from '../../packages/auth/src/better-auth-workers.ts';
import type { DiagnosticRecord } from '../../packages/pgstencil/src/diagnostics.ts';

for (const diagnostics of [false, true])
for (const failure of ['insecure origin', 'email factory'])
test(`Workers auth errors keep security headers: ${failure}, diagnostics ${diagnostics}`, async () => {
const records: DiagnosticRecord[] = [];
const privateError = 'private-email@example.test secret-token';
const app = createBetterAuthWorker({
...(diagnostics
? {
diagnostics: {
sink: (record: DiagnosticRecord) => records.push(record),
},
}
: {}),
email: () => {
if (failure === 'email factory') throw new Error(privateError);
return { send: async () => {} };
},
});
const response = await app.fetch(
new Request('https://auth.example.test/api/auth/get-session'),
{
APP_ORIGIN: `${failure === 'insecure origin' ? 'http' : 'https'}://auth.example.test`,
AUTH_SECRET: 'worker-test-secret-at-least-32-characters',
HYPERDRIVE: { connectionString: 'postgres://unused' },
},
);
expect(response.status).toBe(503);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('referrer-policy')).toBe('no-referrer');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(response.headers.get('x-frame-options')).toBe('DENY');
expect(response.headers.get('content-security-policy')).toBe(
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
);
expect(await response.json()).toEqual({
message: diagnostics
? 'Temporarily unavailable. Please try again.'
: 'Sign-in is temporarily unavailable. Please try again.',
});
if (diagnostics) {
expect(response.headers.get('x-request-id')).toBeTruthy();
expect(
records.some((record) => record.event === 'request.failed'),
).toBe(true);
expect(JSON.stringify(records)).not.toContain(privateError);
}
});
Loading