diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx
index 4e401bdd0f..df374761ef 100644
--- a/docs/content/docs/reference/security.mdx
+++ b/docs/content/docs/reference/security.mdx
@@ -35,8 +35,27 @@ Better Auth includes multiple safeguards to prevent Cross-Site Request Forgery (
3. **Secure Cookie Settings**
Session cookies use the `SameSite=Lax` attribute by default, preventing browsers from sending cookies with most cross-site requests. You can override this behavior using the `defaultCookieAttributes` option.
-4. **No Mutations on GET Requests (with additional safeguards)**
- `GET` requests are assumed to be read-only and should not alter the application’s state. In cases where a `GET` request must perform a mutation—such as during OAuth callbacks - Better Auth applies extra security measures, including validating `nonce` and `state` parameters to ensure the request’s authenticity.
+4. **Fetch Metadata Protection for First-Login CSRF**
+ Better Auth also uses [Fetch Metadata headers](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#fetch_metadata) (`Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`) to provide additional CSRF protection specifically for **first-login scenarios**, where the client does not yet have any cookies.
+
+ This protection applies to **sign-in and sign-up email routes**, which accept form submissions (simple requests) to support progressive enhancement patterns.
+
+ When a sign-in or sign-up request is received **without** any cookies:
+
+ - If the browser indicates a **cross-site navigation** (for example: `Sec-Fetch-Site: cross-site` and `Sec-Fetch-Mode: navigate`), Better Auth **blocks** the request as a potential login CSRF attack.
+
+ - If the request is **same-origin** or **same-site**, Better Auth still validates the `Origin`/`Referer` against your `trustedOrigins` configuration, using the existing origin validation logic.
+
+ - If the client does **not** send Fetch Metadata headers (older browsers, some mobile WebViews, non-browser clients), Better Auth falls back to its **previous behavior** and only performs origin validation when cookies are present, preserving backward compatibility.
+
+ This mechanism allows modern browsers to receive stronger protection against first-login CSRF attacks **without requiring CSRF tokens or client-side JavaScript**, and works seamlessly with progressive enhancement patterns.
+
+
+ Non-browser HTTP clients (mobile apps, server-to-server) should set the `Origin` or `Referer` header appropriately when making requests that include cookies. This ensures proper CSRF validation regardless of Fetch Metadata support.
+
+
+5. **No Mutations on GET Requests (with additional safeguards)**
+ `GET` requests are assumed to be read-only and should not alter the application's state. In cases where a `GET` request must perform a mutation—such as during OAuth callbacks - Better Auth applies extra security measures, including validating `nonce` and `state` parameters to ensure the request's authenticity.
You can skip the CSRF check for all requests by setting the `disableCSRFCheck` option to `true` in the configuration.
diff --git a/e2e/integration/solid-vinxi/src/lib/auth.ts b/e2e/integration/solid-vinxi/src/lib/auth.ts
index 88fc1ef5f8..27c9b42f0c 100644
--- a/e2e/integration/solid-vinxi/src/lib/auth.ts
+++ b/e2e/integration/solid-vinxi/src/lib/auth.ts
@@ -11,6 +11,9 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
+ trustedOrigins: [
+ "http://test.com:3000", // Playwright host mapping if used
+ ],
});
const { runMigrations } = await getMigrations(auth.options);
diff --git a/e2e/integration/vanilla-node/e2e/app.ts b/e2e/integration/vanilla-node/e2e/app.ts
index 85ee9e57b9..4f884aa1a3 100644
--- a/e2e/integration/vanilla-node/e2e/app.ts
+++ b/e2e/integration/vanilla-node/e2e/app.ts
@@ -15,6 +15,11 @@ export async function createAuthServer(
emailAndPassword: {
enabled: true,
},
+ trustedOrigins: [
+ baseURL,
+ "http://localhost:*", // Dynamic frontend port
+ "http://test.com:*", // Cross-domain test
+ ],
});
const { runMigrations } = await getMigrations(auth.options);
diff --git a/e2e/smoke/test/bun.spec.ts b/e2e/smoke/test/bun.spec.ts
index b971c0e186..668a8014b3 100644
--- a/e2e/smoke/test/bun.spec.ts
+++ b/e2e/smoke/test/bun.spec.ts
@@ -41,6 +41,7 @@ describe("(bun) simple server", () => {
}),
headers: {
"content-type": "application/json",
+ origin: `http://localhost:${port}`,
},
},
);
diff --git a/e2e/smoke/test/deno.spec.ts b/e2e/smoke/test/deno.spec.ts
index 64957e0d0e..c5801c676f 100644
--- a/e2e/smoke/test/deno.spec.ts
+++ b/e2e/smoke/test/deno.spec.ts
@@ -40,6 +40,7 @@ describe("(deno) simple server", () => {
}),
headers: {
"content-type": "application/json",
+ origin: `http://localhost:${port}`,
},
},
);
diff --git a/e2e/smoke/test/fixtures/bun-simple.ts b/e2e/smoke/test/fixtures/bun-simple.ts
index 691c9897ae..0db3d335b0 100644
--- a/e2e/smoke/test/fixtures/bun-simple.ts
+++ b/e2e/smoke/test/fixtures/bun-simple.ts
@@ -10,6 +10,9 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
+ trustedOrigins: [
+ "http://localhost:*", // Allow any localhost port for smoke tests
+ ],
logger: {
level: "debug",
},
diff --git a/e2e/smoke/test/fixtures/deno-simple.ts b/e2e/smoke/test/fixtures/deno-simple.ts
index f6e74055c6..c6d3c849cb 100644
--- a/e2e/smoke/test/fixtures/deno-simple.ts
+++ b/e2e/smoke/test/fixtures/deno-simple.ts
@@ -10,6 +10,9 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
+ trustedOrigins: [
+ "http://localhost:*", // Allow any localhost port for smoke tests
+ ],
logger: {
level: "debug",
},
diff --git a/packages/better-auth/src/api/middlewares/origin-check.test.ts b/packages/better-auth/src/api/middlewares/origin-check.test.ts
index d0fda6e60f..a79905a1bc 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.test.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts
@@ -2,6 +2,7 @@ import { createAuthEndpoint } from "@better-auth/core/api";
import { describe, expect, vi } from "vitest";
import * as z from "zod";
import { createAuthClient } from "../../client";
+import { parseSetCookieHeader } from "../../cookies";
import { getTestInstance } from "../../test-utils/test-instance";
import { originCheck } from "./origin-check";
@@ -204,6 +205,256 @@ describe("Origin Check", async (it) => {
});
});
+describe("Fetch Metadata CSRF Protection", async (it) => {
+ const { customFetchImpl, testUser, auth, signInWithTestUser } =
+ await getTestInstance({
+ trustedOrigins: ["http://localhost:3000", "https://app.example.com"],
+ emailAndPassword: {
+ enabled: true,
+ async sendResetPassword(url, user) {},
+ },
+ advanced: {
+ disableCSRFCheck: false,
+ disableOriginCheck: false,
+ },
+ });
+
+ it("should block cross-site navigation on first-login (no session cookie)", async (ctx) => {
+ const maliciousRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: JSON.stringify({
+ email: "attacker@evil.com",
+ password: "password123",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ });
+
+ it("should allow same-origin navigation on first-login (no session cookie)", async (ctx) => {
+ const legitimateRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(legitimateRequest);
+
+ expect(response.status).not.toBe(403);
+ const error = await response.json().catch(() => null);
+ if (error?.message) {
+ expect(error.message).not.toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ }
+ });
+
+ it("should allow same-site navigation on first-login (no session cookie)", async (ctx) => {
+ const legitimateRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://app.example.com",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(legitimateRequest);
+
+ expect(response.status).not.toBe(403);
+ const error = await response.json().catch(() => null);
+ if (error?.message) {
+ expect(error.message).not.toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ }
+ });
+
+ it("should fallback to origin validation when Fetch Metadata is missing", async (ctx) => {
+ const requestWithoutMetadata = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(requestWithoutMetadata);
+
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should use existing origin validation when session cookie exists", async (ctx) => {
+ const signInResponse = await auth.api.signInEmail({
+ body: {
+ email: testUser.email,
+ password: testUser.password,
+ },
+ asResponse: true,
+ });
+
+ const setCookieHeader = signInResponse.headers.get("set-cookie");
+ const cookies = parseSetCookieHeader(setCookieHeader || "");
+ const sessionCookie = cookies.get("better-auth.session_token");
+ if (!sessionCookie) {
+ throw new Error("Failed to get session cookie");
+ }
+
+ const requestWithSession = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: `better-auth.session_token=${sessionCookie.value}`,
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(requestWithSession);
+
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should block cross-site navigation for sign-up endpoint", async (ctx) => {
+ const maliciousRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: JSON.stringify({
+ email: "attacker@evil.com",
+ password: "password123",
+ name: "Attacker",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ });
+
+ it("should allow cors mode requests (fetch/XHR)", async (ctx) => {
+ const fetchRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Dest": "empty",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(fetchRequest);
+
+ expect(response.status).not.toBe(403);
+ const error = await response.json().catch(() => null);
+ if (error?.message) {
+ expect(error.message).not.toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ }
+ });
+
+ it("should allow requests with expired session cookie (cookie presence check)", async (ctx) => {
+ const requestWithExpiredCookie = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: "better-auth.session_token=expired_or_invalid_token",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(requestWithExpiredCookie);
+
+ expect(response.status).not.toBe(403);
+ const error = await response.json().catch(() => null);
+ if (error?.message) {
+ expect(error.message).not.toBe(
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ );
+ }
+ });
+});
+
describe("origin check middleware", async (it) => {
it("should return invalid origin", async () => {
const { client } = await getTestInstance({
diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts
index 114fa889b0..161d268d76 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.ts
@@ -1,10 +1,12 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
+import { BASE_ERROR_CODES } from "@better-auth/core/error";
import { APIError } from "better-call";
+import { matchesOriginPattern } from "../../auth/trusted-origins";
/**
- * A middleware to validate callbackURL and origin against
- * trustedOrigins.
+ * A middleware to validate callbackURL and origin against trustedOrigins.
+ * Also handles CSRF protection using Fetch Metadata for first-login scenarios.
*/
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
// Skip origin check for GET, OPTIONS, HEAD requests - we don't mutate state here.
@@ -16,14 +18,13 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
) {
return;
}
- const headers = ctx.request?.headers;
+ await validateOrigin(ctx);
+
const { body, query } = ctx;
- const originHeader = headers?.get("origin") || headers?.get("referer") || "";
const callbackURL = body?.callbackURL || query?.callbackURL;
const redirectURL = body?.redirectTo;
const errorCallbackURL = body?.errorCallbackURL;
const newUserCallbackURL = body?.newUserCallbackURL;
- const useCookies = headers?.has("cookie");
const validateURL = (url: string | undefined, label: string) => {
if (!url) {
@@ -42,16 +43,7 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
};
- if (
- useCookies &&
- !ctx.context.skipCSRFCheck &&
- !ctx.context.skipOriginCheck
- ) {
- if (!originHeader || originHeader === "null") {
- throw new APIError("FORBIDDEN", { message: "Missing or null Origin" });
- }
- validateURL(originHeader, "origin");
- }
+
callbackURL && validateURL(callbackURL, "callbackURL");
redirectURL && validateURL(redirectURL, "redirectURL");
errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL");
@@ -88,3 +80,114 @@ export const originCheck = (
validateURL(url, "callbackURL");
}
});
+
+/**
+ * Validates origin header against trusted origins.
+ * @param ctx - The endpoint context
+ * @param forceValidate - If true, always validate origin regardless of cookies/skip flags
+ */
+async function validateOrigin(
+ ctx: GenericEndpointContext,
+ forceValidate = false,
+): Promise {
+ const headers = ctx.request?.headers;
+ if (!headers || !ctx.request) {
+ return;
+ }
+ const originHeader = headers.get("origin") || headers.get("referer") || "";
+ const useCookies = headers.has("cookie");
+
+ const shouldValidate =
+ forceValidate ||
+ (useCookies && !ctx.context.skipCSRFCheck && !ctx.context.skipOriginCheck);
+
+ if (!shouldValidate) {
+ return;
+ }
+
+ if (!originHeader || originHeader === "null") {
+ throw new APIError("FORBIDDEN", { message: "Missing or null Origin" });
+ }
+
+ const trustedOrigins: string[] = Array.isArray(
+ ctx.context.options.trustedOrigins,
+ )
+ ? ctx.context.trustedOrigins
+ : [
+ ...ctx.context.trustedOrigins,
+ ...((await ctx.context.options.trustedOrigins?.(ctx.request)) || []),
+ ];
+
+ const isTrustedOrigin = trustedOrigins.some((origin) =>
+ matchesOriginPattern(originHeader, origin),
+ );
+ if (!isTrustedOrigin) {
+ ctx.context.logger.error(`Invalid origin: ${originHeader}`);
+ ctx.context.logger.info(
+ `If it's a valid URL, please add ${originHeader} to trustedOrigins in your auth config\n`,
+ `Current list of trustedOrigins: ${trustedOrigins}`,
+ );
+ throw new APIError("FORBIDDEN", { message: "Invalid origin" });
+ }
+}
+
+/**
+ * Middleware for CSRF protection using Fetch Metadata headers.
+ * This prevents cross-site navigation login attacks while supporting progressive enhancement.
+ */
+export const formCsrfMiddleware = createAuthMiddleware(async (ctx) => {
+ const request = ctx.request;
+ if (!request) {
+ return;
+ }
+
+ await validateFormCsrf(ctx);
+});
+
+/**
+ * Validates CSRF protection for first-login scenarios using Fetch Metadata headers.
+ * This prevents cross-site form submission attacks while supporting progressive enhancement.
+ */
+async function validateFormCsrf(ctx: GenericEndpointContext): Promise {
+ const req = ctx.request;
+ if (!req) {
+ return;
+ }
+
+ const headers = req.headers;
+ const hasAnyCookies = headers.has("cookie");
+
+ if (hasAnyCookies) {
+ return await validateOrigin(ctx);
+ }
+
+ const site = headers.get("Sec-Fetch-Site");
+ const mode = headers.get("Sec-Fetch-Mode");
+ const dest = headers.get("Sec-Fetch-Dest");
+
+ const hasMetadata = Boolean(
+ (site && site.trim()) || (mode && mode.trim()) || (dest && dest.trim()),
+ );
+
+ if (hasMetadata) {
+ // Block cross-site navigation requests (classic CSRF attack pattern)
+ if (site === "cross-site" && mode === "navigate") {
+ ctx.context.logger.error(
+ "Blocked cross-site navigation login attempt (CSRF protection)",
+ {
+ secFetchSite: site,
+ secFetchMode: mode,
+ secFetchDest: dest,
+ },
+ );
+ throw new APIError("FORBIDDEN", {
+ message: BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED,
+ });
+ }
+
+ return await validateOrigin(ctx, true);
+ }
+
+ // No cookies, no Fetch Metadata → fallback to old behavior (no validation)
+ return;
+}
diff --git a/packages/better-auth/src/api/routes/sign-in.test.ts b/packages/better-auth/src/api/routes/sign-in.test.ts
index 4d2a3b55a6..651c47652b 100644
--- a/packages/better-auth/src/api/routes/sign-in.test.ts
+++ b/packages/better-auth/src/api/routes/sign-in.test.ts
@@ -137,3 +137,194 @@ describe("url checks", async (it) => {
);
});
});
+
+describe("sign-in CSRF protection", async (it) => {
+ const { auth, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ });
+
+ it("should block cross-site navigation login attempts (no cookies)", async () => {
+ const maliciousRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: JSON.stringify({
+ email: "attacker@evil.com",
+ password: "password123",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED,
+ );
+ });
+
+ it("should allow same-origin navigation login attempts", async () => {
+ const legitimateRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(legitimateRequest);
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should allow fetch/XHR requests (cors mode)", async () => {
+ const fetchRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Dest": "empty",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(fetchRequest);
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should use origin validation when cookies exist", async () => {
+ const requestWithCookies = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: "some_cookie=value",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(requestWithCookies);
+ // Should not be blocked by CSRF check since cookies exist - uses origin validation instead
+ expect(response.status).not.toBe(403);
+ });
+});
+
+describe("sign-in with form data", async (it) => {
+ const { auth, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ });
+
+ it("should accept form-urlencoded content type", async () => {
+ const formRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: new URLSearchParams({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(formRequest);
+ expect(response.status).toBe(200);
+ const data = await response.json();
+ expect(data.token).toBeDefined();
+ expect(data.user.email).toBe(testUser.email);
+ });
+
+ it("should block cross-site form submissions", async () => {
+ const maliciousFormRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: new URLSearchParams({
+ email: "attacker@evil.com",
+ password: "password123",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousFormRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED,
+ );
+ });
+
+ it("should allow same-site form submissions from trusted origins", async () => {
+ const formRequest = new Request(
+ "http://localhost:3000/api/auth/sign-in/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "same-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: new URLSearchParams({
+ email: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(formRequest);
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts
index 17b865f417..633cf00be9 100644
--- a/packages/better-auth/src/api/routes/sign-in.ts
+++ b/packages/better-auth/src/api/routes/sign-in.ts
@@ -9,6 +9,7 @@ import { parseUserOutput } from "../../db/schema";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import type { InferUser } from "../../types";
import { generateState } from "../../utils";
+import { formCsrfMiddleware } from "../middlewares/origin-check";
import { createEmailVerificationToken } from "./email-verification";
const socialSignInBodySchema = z.object({
@@ -333,6 +334,7 @@ export const signInEmail = () =>
{
method: "POST",
operationId: "signInEmail",
+ use: [formCsrfMiddleware],
body: z.object({
/**
* Email of the user
@@ -371,6 +373,10 @@ export const signInEmail = () =>
.optional(),
}),
metadata: {
+ allowedMediaTypes: [
+ "application/x-www-form-urlencoded",
+ "application/json",
+ ],
$Infer: {
body: {} as {
email: string;
diff --git a/packages/better-auth/src/api/routes/sign-up.test.ts b/packages/better-auth/src/api/routes/sign-up.test.ts
index 546edb1561..b73b09453c 100644
--- a/packages/better-auth/src/api/routes/sign-up.test.ts
+++ b/packages/better-auth/src/api/routes/sign-up.test.ts
@@ -1,3 +1,4 @@
+import { BASE_ERROR_CODES } from "@better-auth/core/error";
import { afterEach, describe, expect, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
@@ -158,8 +159,216 @@ describe("sign-up with custom fields", async (it) => {
}),
).rejects.toThrowError(
expect.objectContaining({
- status: 400,
+ statusCode: 400,
}),
);
});
});
+
+describe("sign-up CSRF protection", async (it) => {
+ const { auth } = await getTestInstance(
+ {
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ it("should block cross-site navigation sign-up attempts (no cookies)", async () => {
+ const maliciousRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: JSON.stringify({
+ email: "victim@example.com",
+ password: "password123",
+ name: "Victim",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED,
+ );
+ });
+
+ it("should allow same-origin navigation sign-up attempts", async () => {
+ const legitimateRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: "newuser@example.com",
+ password: "password123",
+ name: "New User",
+ }),
+ },
+ );
+
+ const response = await auth.handler(legitimateRequest);
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should allow fetch/XHR sign-up requests (cors mode)", async () => {
+ const fetchRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Dest": "empty",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: "fetchuser@example.com",
+ password: "password123",
+ name: "Fetch User",
+ }),
+ },
+ );
+
+ const response = await auth.handler(fetchRequest);
+ expect(response.status).not.toBe(403);
+ });
+
+ it("should use origin validation when cookies exist", async () => {
+ const requestWithCookies = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: "some_cookie=value",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ origin: "http://localhost:3000",
+ },
+ body: JSON.stringify({
+ email: "cookieuser@example.com",
+ password: "password123",
+ name: "Cookie User",
+ }),
+ },
+ );
+
+ const response = await auth.handler(requestWithCookies);
+ // Should not be blocked by CSRF check since cookies exist - uses origin validation instead
+ expect(response.status).not.toBe(403);
+ });
+});
+
+describe("sign-up with form data", async (it) => {
+ const { auth } = await getTestInstance(
+ {
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ },
+ {
+ disableTestUser: true,
+ },
+ );
+
+ it("should accept form-urlencoded content type", async () => {
+ const formRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "same-origin",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: new URLSearchParams({
+ email: "formuser@example.com",
+ password: "password123",
+ name: "Form User",
+ }),
+ },
+ );
+
+ const response = await auth.handler(formRequest);
+ expect(response.status).toBe(200);
+ const data = await response.json();
+ expect(data.token).toBeDefined();
+ expect(data.user.email).toBe("formuser@example.com");
+ });
+
+ it("should block cross-site form submissions", async () => {
+ const maliciousFormRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "cross-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "https://evil.com",
+ },
+ body: new URLSearchParams({
+ email: "victim@example.com",
+ password: "password123",
+ name: "Victim",
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousFormRequest);
+ expect(response.status).toBe(403);
+ const error = await response.json();
+ expect(error.message).toBe(
+ BASE_ERROR_CODES.CROSS_SITE_NAVIGATION_LOGIN_BLOCKED,
+ );
+ });
+
+ it("should allow same-site form submissions from trusted origins", async () => {
+ const formRequest = new Request(
+ "http://localhost:3000/api/auth/sign-up/email",
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ "Sec-Fetch-Site": "same-site",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Dest": "document",
+ origin: "http://localhost:3000",
+ },
+ body: new URLSearchParams({
+ email: "samesiteuser@example.com",
+ password: "password123",
+ name: "Same Site User",
+ }),
+ },
+ );
+
+ const response = await auth.handler(formRequest);
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts
index 191c3f445d..27f1052059 100644
--- a/packages/better-auth/src/api/routes/sign-up.ts
+++ b/packages/better-auth/src/api/routes/sign-up.ts
@@ -9,6 +9,8 @@ import { setSessionCookie } from "../../cookies";
import { parseUserInput } from "../../db";
import { parseUserOutput } from "../../db/schema";
import type { AdditionalUserFieldsInput, InferUser, User } from "../../types";
+
+import { formCsrfMiddleware } from "../middlewares/origin-check";
import { createEmailVerificationToken } from "./email-verification";
const signUpEmailBodySchema = z
@@ -28,8 +30,13 @@ export const signUpEmail = () =>
{
method: "POST",
operationId: "signUpWithEmailAndPassword",
- body: signUpEmailBodySchema,
+ use: [formCsrfMiddleware],
+ body: z.record(z.string(), z.any()),
metadata: {
+ allowedMediaTypes: [
+ "application/x-www-form-urlencoded",
+ "application/json",
+ ],
$Infer: {
body: {} as {
name: string;
@@ -204,6 +211,12 @@ export const signUpEmail = () =>
});
}
+ if (!password || typeof password !== "string") {
+ throw new APIError("BAD_REQUEST", {
+ message: BASE_ERROR_CODES.INVALID_PASSWORD,
+ });
+ }
+
const minPasswordLength = ctx.context.password.config.minPasswordLength;
if (password.length < minPasswordLength) {
ctx.context.logger.error("Password is too short");
diff --git a/packages/better-auth/src/api/to-auth-endpoints.ts b/packages/better-auth/src/api/to-auth-endpoints.ts
index fd9fd2db7a..0c69ae1a89 100644
--- a/packages/better-auth/src/api/to-auth-endpoints.ts
+++ b/packages/better-auth/src/api/to-auth-endpoints.ts
@@ -6,7 +6,6 @@ import {
runWithRequestState,
} from "@better-auth/core/context";
import { shouldPublishLog } from "@better-auth/core/env";
-import { APIError } from "@better-auth/core/error";
import type {
EndpointContext,
EndpointOptions,
diff --git a/packages/core/src/error/codes.ts b/packages/core/src/error/codes.ts
index 0f9e1043a3..75153b5e6d 100644
--- a/packages/core/src/error/codes.ts
+++ b/packages/core/src/error/codes.ts
@@ -28,4 +28,23 @@ export const BASE_ERROR_CODES = defineErrorCodes({
ACCOUNT_NOT_FOUND: "Account not found",
USER_ALREADY_HAS_PASSWORD:
"User already has a password. Provide that to delete the account.",
+ CROSS_SITE_NAVIGATION_LOGIN_BLOCKED:
+ "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
+ VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled",
+ EMAIL_ALREADY_VERIFIED: "Email is already verified",
+ EMAIL_MISMATCH: "Email mismatch",
+ SESSION_NOT_FRESH: "Session is not fresh",
+ LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists",
+ INVALID_ORIGIN: "Invalid origin",
+ INVALID_CALLBACK_URL: "Invalid callbackURL",
+ INVALID_REDIRECT_URL: "Invalid redirectURL",
+ INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL",
+ INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL",
+ MISSING_OR_NULL_ORIGIN: "Missing or null Origin",
+ CALLBACK_URL_REQUIRED: "callbackURL is required",
+ FAILED_TO_CREATE_VERIFICATION: "Unable to create verification",
+ FIELD_NOT_ALLOWED: "Field not allowed to be set",
+ ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported",
+ VALIDATION_ERROR: "Validation Error",
+ MISSING_FIELD: "Field is required",
});
diff --git a/packages/stripe/src/client.ts b/packages/stripe/src/client.ts
index 92931c7e4a..fa06cde7cc 100644
--- a/packages/stripe/src/client.ts
+++ b/packages/stripe/src/client.ts
@@ -28,7 +28,6 @@ export const stripeClient = <
>
>,
pathMethods: {
- "/subscription/restore": "POST",
"/subscription/billing-portal": "POST",
"/subscription/restore": "POST",
},