diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx
index 42a107b718..ecc337098b 100644
--- a/docs/content/docs/reference/options.mdx
+++ b/docs/content/docs/reference/options.mdx
@@ -532,8 +532,8 @@ export const auth = betterAuth({
- `ipAddress`: IP address configuration for rate limiting and session tracking
- `useSecureCookies`: Use secure cookies (default: `false`)
-- `disableCSRFCheck`: Disable trusted origins check (⚠️ security risk)
-- `disableOriginCheck`: Disable origin check (⚠️ security risk)
+- `disableCSRFCheck`: Disable all CSRF protection including origin header validation and Fetch Metadata checks (⚠️ security risk)
+- `disableOriginCheck`: Disable URL validation for `callbackURL`, `redirectTo`, and other redirect URLs (⚠️ security risk)
- `crossSubDomainCookies`: Configure cookies to be shared across subdomains
- `cookies`: Customize cookie names and attributes
- `defaultCookieAttributes`: Default attributes for all cookies
diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx
index df374761ef..b641bc9d69 100644
--- a/docs/content/docs/reference/security.mdx
+++ b/docs/content/docs/reference/security.mdx
@@ -57,7 +57,16 @@ Better Auth includes multiple safeguards to prevent Cross-Site Request Forgery (
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.
+### Disabling Security Checks
+
+Better Auth provides two separate options to disable security checks. These options control different aspects of security:
+
+#### `disableCSRFCheck`
+
+Disables **all CSRF protection**, including:
+- Origin header validation when cookies are present
+- Fetch Metadata checks (`Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`)
+- Cross-site navigation blocking for first-login scenarios
```typescript
{
@@ -67,7 +76,17 @@ You can skip the CSRF check for all requests by setting the `disableCSRFCheck` o
}
```
-You can skip the origin check for all requests by setting the `disableOriginCheck` option to `true` in the configuration.
+
+ Disabling CSRF checks allows requests from any origin to use cookies and perform actions on behalf of users. This opens your application to CSRF attacks.
+
+
+#### `disableOriginCheck`
+
+Disables **URL validation** against `trustedOrigins`, including:
+- `callbackURL` validation
+- `redirectTo` validation
+- `errorCallbackURL` validation
+- `newUserCallbackURL` validation
```typescript
{
@@ -78,9 +97,20 @@ You can skip the origin check for all requests by setting the `disableOriginChec
```
- Skipping csrf check will open your application to CSRF attacks. And skipping origin check may open up your application to other security vulnerabilities including open redirects.
+ Disabling origin checks allows any URL to be used in redirects and callbacks. This opens your application to open redirect vulnerabilities.
+
+ For backward compatibility, `disableOriginCheck: true` also disables CSRF protection. If you only want to disable URL validation without affecting CSRF protection, this is not currently possible - both checks are disabled together when using this option.
+
+
+#### Summary
+
+| Option | What it disables |
+|--------|-----------------|
+| `disableCSRFCheck` | CSRF protection only (origin header validation, Fetch Metadata checks) |
+| `disableOriginCheck` | URL validation AND CSRF protection (for backward compatibility) |
+
## OAuth State and PKCE
To secure OAuth flows, Better Auth stores the OAuth state and PKCE (Proof Key for Code Exchange) in the database. The state helps prevent CSRF attacks, while PKCE protects against code injection threats. Once the OAuth process completes, these values are removed from the database.
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 7eae68d8cb..46c3e49aac 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.test.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts
@@ -497,6 +497,9 @@ describe("origin check middleware", async (it) => {
it("should return invalid origin", async () => {
const { client } = await getTestInstance({
trustedOrigins: ["https://trusted-site.com"],
+ advanced: {
+ disableOriginCheck: false,
+ },
plugins: [
{
id: "test",
@@ -728,3 +731,224 @@ describe("trusted origins with baseURL inferred from request", async (it) => {
}
});
});
+
+describe("disableCSRFCheck and disableOriginCheck separation", async (it) => {
+ it("disableCSRFCheck should allow untrusted origins with cookies (CSRF bypass)", async () => {
+ const { customFetchImpl, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableCSRFCheck: true,
+ disableOriginCheck: false,
+ },
+ });
+
+ const client = createAuthClient({
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ headers: {
+ origin: "http://evil-site.com",
+ cookie: "session=test",
+ },
+ },
+ });
+
+ // Should succeed because CSRF check is disabled (origin header not validated)
+ const res = await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ // But callbackURL should still be validated by origin check
+ callbackURL: "http://localhost:3000/dashboard",
+ });
+
+ expect(res.data?.user).toBeDefined();
+ });
+
+ it("disableCSRFCheck should still validate callbackURL (origin check still active)", async () => {
+ const { customFetchImpl, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableCSRFCheck: true,
+ disableOriginCheck: false,
+ },
+ });
+
+ const client = createAuthClient({
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ headers: {
+ origin: "http://evil-site.com",
+ cookie: "session=test",
+ },
+ },
+ });
+
+ // Origin header passes (CSRF disabled), but callbackURL should fail
+ const res = await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ callbackURL: "http://malicious-site.com/steal",
+ });
+
+ expect(res.error?.status).toBe(403);
+ expect(res.error?.message).toBe("Invalid callbackURL");
+ });
+
+ it("disableOriginCheck should allow untrusted callbackURL", async () => {
+ const { customFetchImpl, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableCSRFCheck: false,
+ disableOriginCheck: true,
+ },
+ });
+
+ const client = createAuthClient({
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ headers: {
+ origin: "http://localhost:3000",
+ cookie: "session=test",
+ },
+ },
+ });
+
+ // Origin header is trusted, and callbackURL validation is disabled
+ const res = await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ callbackURL: "http://any-site.com/redirect",
+ });
+
+ expect(res.data?.user).toBeDefined();
+ });
+
+ it("disableOriginCheck also disables CSRF for backward compatibility", async () => {
+ const { customFetchImpl, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableOriginCheck: true,
+ },
+ });
+
+ const warnFn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const client = createAuthClient({
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ headers: {
+ origin: "http://evil-site.com",
+ cookie: "session=test",
+ },
+ },
+ });
+
+ expect(warnFn).toHaveBeenCalledTimes(0);
+ // disableOriginCheck: true also disables CSRF for backward compatibility
+ // so this should succeed even with an untrusted origin
+ const res = await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ callbackURL: "http://any-site.com/redirect",
+ });
+ expect(warnFn).toHaveBeenCalledTimes(1);
+
+ expect(warnFn).toHaveBeenCalledWith(
+ expect.stringMatching(/^\[Deprecation]/),
+ );
+
+ expect(res.data?.user).toBeDefined();
+ {
+ await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ callbackURL: "http://any-site.com/redirect",
+ });
+ expect(warnFn).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it("disableCSRFCheck should bypass Fetch Metadata CSRF protection", async () => {
+ const { auth, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableCSRFCheck: true,
+ disableOriginCheck: false,
+ },
+ });
+
+ // Cross-site navigation that would normally be blocked
+ 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: testUser.email,
+ password: testUser.password,
+ }),
+ },
+ );
+
+ const response = await auth.handler(maliciousRequest);
+ // Should NOT be blocked because CSRF check is disabled
+ expect(response.status).not.toBe(403);
+ });
+
+ it("both flags disabled should bypass all checks", async () => {
+ const { customFetchImpl, testUser } = await getTestInstance({
+ trustedOrigins: ["http://localhost:3000"],
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ disableCSRFCheck: true,
+ disableOriginCheck: true,
+ },
+ });
+
+ const client = createAuthClient({
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ headers: {
+ origin: "http://evil-site.com",
+ cookie: "session=test",
+ },
+ },
+ });
+
+ // Both CSRF and origin checks are disabled
+ const res = await client.signIn.email({
+ email: testUser.email,
+ password: testUser.password,
+ callbackURL: "http://malicious-site.com/steal",
+ });
+
+ expect(res.data?.user).toBeDefined();
+ });
+});
diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts
index 326687db31..05d3ddf241 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.ts
@@ -1,8 +1,32 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
+import { deprecate } from "@better-auth/core/utils";
import { matchesOriginPattern } from "../../auth/trusted-origins";
+/**
+ * Checks if CSRF should be skipped for backward compatibility.
+ * Previously, disableOriginCheck also disabled CSRF checks.
+ * This maintains that behavior when disableCSRFCheck isn't explicitly set.
+ */
+function shouldSkipCSRFForBackwardCompat(ctx: GenericEndpointContext): boolean {
+ return (
+ ctx.context.skipOriginCheck &&
+ ctx.context.options.advanced?.disableCSRFCheck === undefined
+ );
+}
+
+/**
+ * Logs deprecation warning for users relying on coupled behavior.
+ * Only logs if user explicitly set disableOriginCheck (not test environment default).
+ */
+const logBackwardCompatWarning = deprecate(
+ function logBackwardCompatWarning() {},
+ "disableOriginCheck: true currently also disables CSRF checks. " +
+ "In a future version, disableOriginCheck will ONLY disable URL validation. " +
+ "To keep CSRF disabled, add disableCSRFCheck: true to your config.",
+);
+
/**
* A middleware to validate callbackURL and origin against trustedOrigins.
* Also handles CSRF protection using Fetch Metadata for first-login scenarios.
@@ -19,6 +43,10 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
}
await validateOrigin(ctx);
+ if (ctx.context.skipOriginCheck) {
+ return;
+ }
+
const { body, query } = ctx;
const callbackURL = body?.callbackURL || query?.callbackURL;
const redirectURL = body?.redirectTo;
@@ -87,6 +115,9 @@ export const originCheck = (
if (!ctx.request) {
return;
}
+ if (ctx.context.skipOriginCheck) {
+ return;
+ }
const callbackURL = getValue(ctx);
const validateURL = (url: string | undefined, label: string) => {
if (!url) {
@@ -156,9 +187,17 @@ async function validateOrigin(
const originHeader = headers.get("origin") || headers.get("referer") || "";
const useCookies = headers.has("cookie");
- const shouldValidate =
- forceValidate ||
- (useCookies && !ctx.context.skipCSRFCheck && !ctx.context.skipOriginCheck);
+ if (ctx.context.skipCSRFCheck) {
+ return;
+ }
+
+ if (shouldSkipCSRFForBackwardCompat(ctx)) {
+ ctx.context.options.advanced?.disableOriginCheck === true &&
+ logBackwardCompatWarning();
+ return;
+ }
+
+ const shouldValidate = forceValidate || useCookies;
if (!shouldValidate) {
return;
@@ -215,6 +254,14 @@ async function validateFormCsrf(ctx: GenericEndpointContext): Promise {
return;
}
+ if (ctx.context.skipCSRFCheck) {
+ return;
+ }
+
+ if (shouldSkipCSRFForBackwardCompat(ctx)) {
+ return;
+ }
+
const headers = req.headers;
const hasAnyCookies = headers.has("cookie");
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 38b9330b4b..46e4d87397 100644
--- a/packages/better-auth/src/api/routes/sign-in.test.ts
+++ b/packages/better-auth/src/api/routes/sign-in.test.ts
@@ -107,7 +107,11 @@ describe("sign-in", async (it) => {
describe("url checks", async (it) => {
it("should reject untrusted origins", async () => {
- const { client } = await getTestInstance();
+ const { client } = await getTestInstance({
+ advanced: {
+ disableOriginCheck: false,
+ },
+ });
const res = await client.signIn.social({
provider: "google",
callbackURL: "http://malicious.com",
@@ -139,6 +143,9 @@ describe("sign-in CSRF protection", async (it) => {
emailAndPassword: {
enabled: true,
},
+ advanced: {
+ disableCSRFCheck: false,
+ },
});
it("should block cross-site navigation login attempts (no cookies)", async () => {
@@ -245,6 +252,9 @@ describe("sign-in with form data", async (it) => {
emailAndPassword: {
enabled: true,
},
+ advanced: {
+ disableCSRFCheck: false,
+ },
});
it("should accept form-urlencoded content type", async () => {
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 5d1fce9fe7..f283fa2855 100644
--- a/packages/better-auth/src/api/routes/sign-up.test.ts
+++ b/packages/better-auth/src/api/routes/sign-up.test.ts
@@ -172,6 +172,9 @@ describe("sign-up CSRF protection", async (it) => {
emailAndPassword: {
enabled: true,
},
+ advanced: {
+ disableCSRFCheck: false,
+ },
},
{
disableTestUser: true,
@@ -287,6 +290,9 @@ describe("sign-up with form data", async (it) => {
emailAndPassword: {
enabled: true,
},
+ advanced: {
+ disableCSRFCheck: false,
+ },
},
{
disableTestUser: true,
diff --git a/packages/better-auth/src/cookies/cookies.test.ts b/packages/better-auth/src/cookies/cookies.test.ts
index 0922212828..3a2cd21e37 100644
--- a/packages/better-auth/src/cookies/cookies.test.ts
+++ b/packages/better-auth/src/cookies/cookies.test.ts
@@ -37,15 +37,25 @@ describe("cookies", async () => {
});
it("should set multiple cookies", async () => {
- await client.signIn.social(
+ const { client, testUser } = await getTestInstance({
+ session: {
+ cookieCache: {
+ enabled: true,
+ },
+ },
+ });
+ await client.signIn.email(
{
- provider: "github",
- callbackURL: "https://example.com",
+ email: testUser.email,
+ password: testUser.password,
},
{
onSuccess(context) {
const cookies = context.response.headers.get("Set-Cookie");
- expect(cookies?.split(",").length).toBeGreaterThan(1);
+ expect(cookies).toBeDefined();
+ const parsed = parseSetCookieHeader(cookies!);
+ // With cookie cache enabled, we should have session_token and session_data cookies
+ expect(parsed.size).toBeGreaterThan(1);
},
},
);
diff --git a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts
index c80947980a..8d0fd47e9d 100644
--- a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts
+++ b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts
@@ -343,6 +343,9 @@ describe("magic link verify origin validation", async () => {
},
}),
],
+ advanced: {
+ disableOriginCheck: false,
+ },
});
const client = createAuthClient({
diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts
index fbc8b0834f..335dfac380 100644
--- a/packages/better-auth/src/social.test.ts
+++ b/packages/better-auth/src/social.test.ts
@@ -140,6 +140,9 @@ describe("Social Providers", async (c) => {
clientSecret: "test",
},
},
+ advanced: {
+ disableOriginCheck: false,
+ },
},
{
disableTestUser: true,
diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts
index 60016a895c..ee631b7716 100644
--- a/packages/core/src/types/init-options.ts
+++ b/packages/core/src/types/init-options.ts
@@ -151,17 +151,32 @@ export type BetterAuthAdvancedOptions = {
*/
useSecureCookies?: boolean | undefined;
/**
- * Disable trusted origins check
+ * Disable all CSRF protection.
+ *
+ * When enabled, this disables:
+ * - Origin header validation when cookies are present
+ * - Fetch Metadata checks (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest)
+ * - Cross-site navigation blocking for first-login scenarios
*
* ⚠︎ This is a security risk and it may expose your application to
* CSRF attacks
+ *
+ * @default false
*/
disableCSRFCheck?: boolean | undefined;
/**
- * Disable origin check
+ * Disable URL validation against trustedOrigins.
*
- * ⚠︎ This may allow requests from any origin to be processed by
- * Better Auth. And could lead to security vulnerabilities.
+ * When enabled, this disables validation of:
+ * - callbackURL
+ * - redirectTo
+ * - errorCallbackURL
+ * - newUserCallbackURL
+ *
+ * ⚠︎ This may allow open redirects and could lead to security
+ * vulnerabilities.
+ *
+ * @default false
*/
disableOriginCheck?: boolean | undefined;
/**