feat: support form data for email sign-in/sign-up and fallback to checking fetch Metadata for first login (#6314)

Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com>
Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
Co-authored-by: Jonathan Samines <jn.samines@gmail.com>
This commit is contained in:
Paola Estefanía de Campos
2025-12-31 21:36:19 +08:00
committed by Alex Yang
co-authored by Taesu Bereket Engida Jonathan Samines
parent 5484706ceb
commit 3f06dd2ddd
16 changed files with 846 additions and 21 deletions
+21 -2
View File
@@ -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.
<Callout type="info">
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.
</Callout>
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.
@@ -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);
+5
View File
@@ -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);
+1
View File
@@ -41,6 +41,7 @@ describe("(bun) simple server", () => {
}),
headers: {
"content-type": "application/json",
origin: `http://localhost:${port}`,
},
},
);
+1
View File
@@ -40,6 +40,7 @@ describe("(deno) simple server", () => {
}),
headers: {
"content-type": "application/json",
origin: `http://localhost:${port}`,
},
},
);
+3
View File
@@ -10,6 +10,9 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
trustedOrigins: [
"http://localhost:*", // Allow any localhost port for smoke tests
],
logger: {
level: "debug",
},
+3
View File
@@ -10,6 +10,9 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
trustedOrigins: [
"http://localhost:*", // Allow any localhost port for smoke tests
],
logger: {
level: "debug",
},
@@ -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({
@@ -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<void> {
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<void> {
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;
}
@@ -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);
});
});
@@ -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 = <O extends BetterAuthOptions>() =>
{
method: "POST",
operationId: "signInEmail",
use: [formCsrfMiddleware],
body: z.object({
/**
* Email of the user
@@ -371,6 +373,10 @@ export const signInEmail = <O extends BetterAuthOptions>() =>
.optional(),
}),
metadata: {
allowedMediaTypes: [
"application/x-www-form-urlencoded",
"application/json",
],
$Infer: {
body: {} as {
email: string;
@@ -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);
});
});
+14 -1
View File
@@ -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 = <O extends BetterAuthOptions>() =>
{
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 = <O extends BetterAuthOptions>() =>
});
}
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");
@@ -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,
+19
View File
@@ -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",
});
-1
View File
@@ -28,7 +28,6 @@ export const stripeClient = <
>
>,
pathMethods: {
"/subscription/restore": "POST",
"/subscription/billing-portal": "POST",
"/subscription/restore": "POST",
},