diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx
index 8690d78d37..f8aa3531e1 100644
--- a/docs/content/docs/concepts/plugins.mdx
+++ b/docs/content/docs/concepts/plugins.mdx
@@ -125,6 +125,8 @@ Create Auth endpoints wraps around `createEndpoint` from Better Call. Inside the
- `adapter`: This is the same as db but it give you `orm` like functions to interact with the database. (we recommend using this over `db` unless you need raw sql queries or for performance reasons)
- `internalAdapter`: These are internal db calls that are used by Better Auth. For example, you can use these calls to create a session instead of using `adapter` directly. `internalAdapter.createSession(userId)`
- `createAuthCookie`: This is a helper function that lets you get a cookie `name` and `options` for either to `set` or `get` cookies. It implements things like `__Secure-` prefix for cookies based on whether the connection is secure (HTTPS) or the application is running in production mode.
+- `trustedOrigins`: This is the list of trusted origins that you specified via `options.trustedOrigins`.
+- `isTrustedOrigin`: This is a helper function that allows you to quickly check whether a given url or path is trusted based on the trusted origins configuration.
For other properties, you can check the Better Call documentation and the source code .
@@ -355,6 +357,46 @@ const myPlugin = () => {
}
```
+### Trusted origins
+
+If you're building custom plugins or endpoints, you can use the `isTrustedOrigin()` method available on the auth context to validate URLs against your trusted origins configuration.
+This ensures your custom endpoints respect the same security settings as Better Auth's built-in endpoints.
+
+```ts title="plugin.ts"
+import { createAuthEndpoint, APIError } from "better-auth/api";
+import * as z from "zod"
+
+const myPlugin = () => {
+ return {
+ id: "my-plugin",
+ trustedOrigins: [
+ "http://trusted.com"
+ ],
+ endpoints: {
+ getTrustedHelloWorld: createAuthEndpoint("/my-plugin/hello-world", {
+ method: "GET",
+ query: z.object({
+ url: z.string()
+ }),
+ }, async (ctx) => {
+ // The allowRelativePaths option can be used to either allow or disallow relative paths
+ if (!ctx.context.isTrustedOrigin(ctx.query.url, { allowRelativePaths: false })) {
+ throw new APIError("FORBIDDEN", {
+ message: "origin is not trusted."
+ });
+ }
+
+ return ctx.json({
+ message: "Hello World"
+ })
+ })
+ }
+ } satisfies BetterAuthPlugin
+}
+```
+
+See the [trusted origins and security](/docs/reference/security#trusted-origins) docs for more info.
+
### Server-plugin helper functions
Some additional helper functions for creating server plugins.
diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx
index 3dc808e0b1..77d6f0a119 100644
--- a/docs/content/docs/reference/security.mdx
+++ b/docs/content/docs/reference/security.mdx
@@ -120,7 +120,7 @@ The most basic usage is to specify exact origins:
}
```
-### Wildcard Domains
+### Wildcard Origins
Better Auth supports wildcard patterns in trusted origins, which allows you to trust multiple subdomains with a single entry:
@@ -162,6 +162,23 @@ Trusted origins also support custom schemes for mobile apps and browser extensio
}
```
+### Dynamic origin list
+
+You can also dynamically set the list of trusted origins by providing a function that returns it:
+
+```typescript
+{
+ trustedOrigins: async (request) => {
+ const trustedOrigins = await queryTrustedDomains();
+ return trustedOrigins;
+ }
+}
+```
+
+
+**Important**: This function will be invoked per incoming request, so be careful if you decide to dynamically fetch your list of trusted domains.
+
+
## Reporting Vulnerabilities
If you discover a security vulnerability in Better Auth, please report it to us at [security@better-auth.com](mailto:security@better-auth.com). We address all reports promptly, and credits will be given for validated discoveries.
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 0345aee98d..16839a1971 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.test.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.test.ts
@@ -11,7 +11,6 @@ describe("Origin Check", async (it) => {
"http://localhost:5000",
"https://trusted.com",
"*.my-site.com",
- "https://*.protocol-site.com",
],
emailAndPassword: {
enabled: true,
@@ -57,115 +56,6 @@ describe("Origin Check", async (it) => {
expect(res.error?.message).toBe("Invalid callbackURL");
});
- it("should allow query params in callback url", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://localhost:3000",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "/dashboard?test=123",
- });
- expect(res.data?.user).toBeDefined();
- });
-
- it("should allow plus signs in the callback url", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://localhost:3000",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "/dashboard+page?test=123+456",
- });
- expect(res.data?.user).toBeDefined();
- });
-
- it("should reject callback url with double slash", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://localhost:3000",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "//evil.com",
- });
- expect(res.error?.status).toBe(403);
- });
-
- it("should reject callback urls with encoded malicious content", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://localhost:3000",
- },
- },
- });
-
- const maliciousPatterns = [
- "/%5C/evil.com",
- `/\\/\\/evil.com`,
- "/%5C/evil.com",
- "/..%2F..%2Fevil.com",
- "javascript:alert('xss')",
- "data:text/html,",
- ];
-
- for (const pattern of maliciousPatterns) {
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: pattern,
- });
- expect(res.error?.status).toBe(403);
- }
- });
-
- it("should reject callback url with malicious domain with wildcard trusted origins", async (ctx) => {
- const { customFetchImpl, testUser } = await getTestInstance({
- trustedOrigins: ["*.example.com"],
- emailAndPassword: {
- enabled: true,
- async sendResetPassword(url, user) {},
- },
- });
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- cookie: "session=123",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "malicious.com?.example.com",
- });
- expect(res.error?.status).toBe(403);
- });
-
it("should reject untrusted origin headers", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
@@ -184,42 +74,6 @@ describe("Origin Check", async (it) => {
expect(res.error?.status).toBe(403);
});
- it("should reject untrusted origin headers which start with trusted origin", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://trusted.com.malicious.com",
- cookie: "session=123",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- });
- expect(res.error?.status).toBe(403);
- });
-
- it("should reject untrusted origin subdomains", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "http://sub-domain.trusted.com",
- cookie: "session=123",
- },
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- });
- expect(res.error?.status).toBe(403);
- });
-
it("should allow untrusted origin if they don't contain cookies", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
@@ -296,23 +150,6 @@ describe("Origin Check", async (it) => {
callbackURL: "https://sub-domain.my-site.com/callback",
});
expect(res.data?.user).toBeDefined();
-
- // Test another subdomain with the wildcard pattern
- const client2 = createAuthClient({
- baseURL: "https://another-sub.my-site.com",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://another-sub.my-site.com",
- },
- },
- });
- const res2 = await client2.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "https://another-sub.my-site.com/callback",
- });
- expect(res2.data?.user).toBeDefined();
});
it("should work with GET requests", async (ctx) => {
@@ -365,112 +202,6 @@ describe("Origin Check", async (it) => {
});
expect(invalidRes.error?.status).toBe(403);
});
-
- it("should work with relative callbackURL with query params", async (ctx) => {
- const client = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- },
- });
- const res = await client.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "/dashboard?email=123@email.com",
- });
- expect(res.data?.user).toBeDefined();
- });
-
- it("should work with protocol specific wildcard trusted origins", async () => {
- // Test HTTPS protocol specific wildcard - should work
- const httpsClient = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "https://api.protocol-site.com",
- cookie: "session=123",
- },
- },
- });
- const httpsRes = await httpsClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "https://app.protocol-site.com/dashboard",
- });
- expect(httpsRes.data?.user).toBeDefined();
-
- // Test HTTP with HTTPS protocol wildcard - should fail
- const httpClient = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- headers: {
- origin: "http://api.protocol-site.com",
- cookie: "session=123",
- },
- },
- });
- const httpRes = await httpClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- });
- expect(httpRes.error?.status).toBe(403);
- });
-
- it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => {
- const { customFetchImpl, testUser } = await getTestInstance({
- trustedOrigins: [
- "exp://10.0.0.*:*/*",
- "exp://192.168.*.*:*/*",
- "exp://172.*.*.*:*/*",
- ],
- emailAndPassword: {
- enabled: true,
- async sendResetPassword(url, user) {},
- },
- });
-
- // Test custom scheme with wildcard - should work
- const expoClient = createAuthClient({
- baseURL: "http://localhost:3000",
- fetchOptions: {
- customFetchImpl,
- },
- });
-
- // Test with IP matching the wildcard pattern
- const resWithIP = await expoClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "exp://10.0.0.29:8081/--/",
- });
- expect(resWithIP.data?.user).toBeDefined();
-
- // Test with different IP range that matches
- const resWithIP2 = await expoClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "exp://192.168.1.100:8081/--/",
- });
- expect(resWithIP2.data?.user).toBeDefined();
-
- // Test with different IP range that matches
- const resWithIP3 = await expoClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "exp://172.16.0.1:8081/--/",
- });
- expect(resWithIP3.data?.user).toBeDefined();
-
- // Test with IP that doesn't match any pattern - should fail
- const resWithUnmatchedIP = await expoClient.signIn.email({
- email: testUser.email,
- password: testUser.password,
- callbackURL: "exp://203.0.113.0:8081/--/",
- });
- expect(resWithUnmatchedIP.error?.status).toBe(403);
- });
});
describe("origin check middleware", async (it) => {
diff --git a/packages/better-auth/src/api/middlewares/origin-check.ts b/packages/better-auth/src/api/middlewares/origin-check.ts
index d985997858..114fa889b0 100644
--- a/packages/better-auth/src/api/middlewares/origin-check.ts
+++ b/packages/better-auth/src/api/middlewares/origin-check.ts
@@ -1,8 +1,6 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { APIError } from "better-call";
-import { getHost, getOrigin, getProtocol } from "../../utils/url";
-import { wildcardMatch } from "../../utils/wildcard";
/**
* A middleware to validate callbackURL and origin against
@@ -19,59 +17,27 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
return;
}
const headers = ctx.request?.headers;
- const request = ctx.request;
- const { body, query, context } = 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 trustedOrigins: string[] = Array.isArray(context.options.trustedOrigins)
- ? context.trustedOrigins
- : [
- ...context.trustedOrigins,
- ...((await context.options.trustedOrigins?.(request)) || []),
- ];
const useCookies = headers?.has("cookie");
- const matchesPattern = (url: string, pattern: string): boolean => {
- if (url.startsWith("/")) {
- return false;
- }
- if (pattern.includes("*")) {
- // For protocol-specific wildcards, match the full origin
- if (pattern.includes("://")) {
- return wildcardMatch(pattern)(getOrigin(url) || url);
- }
- const host = getHost(url);
- if (!host) {
- return false;
- }
- return wildcardMatch(pattern)(host);
- }
-
- const protocol = getProtocol(url);
- return protocol === "http:" || protocol === "https:" || !protocol
- ? pattern === getOrigin(url)
- : url.startsWith(pattern);
- };
const validateURL = (url: string | undefined, label: string) => {
if (!url) {
return;
}
- const isTrustedOrigin = trustedOrigins.some(
- (origin) =>
- matchesPattern(url, origin) ||
- (url?.startsWith("/") &&
- label !== "origin" &&
- /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url)),
- );
+ const isTrustedOrigin = ctx.context.isTrustedOrigin(url, {
+ allowRelativePaths: label !== "origin",
+ });
+
if (!isTrustedOrigin) {
ctx.context.logger.error(`Invalid ${label}: ${url}`);
ctx.context.logger.info(
`If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`,
- `Current list of trustedOrigins: ${trustedOrigins}`,
+ `Current list of trustedOrigins: ${ctx.context.trustedOrigins}`,
);
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
@@ -99,57 +65,20 @@ export const originCheck = (
if (!ctx.request) {
return;
}
- const { context } = ctx;
const callbackURL = getValue(ctx);
- const trustedOrigins: string[] = Array.isArray(
- context.options.trustedOrigins,
- )
- ? context.trustedOrigins
- : [
- ...context.trustedOrigins,
- ...((await context.options.trustedOrigins?.(ctx.request)) || []),
- ];
-
- const matchesPattern = (url: string, pattern: string): boolean => {
- if (url.startsWith("/")) {
- return false;
- }
- if (pattern.includes("*")) {
- // For protocol-specific wildcards, match the full origin
- if (pattern.includes("://")) {
- return wildcardMatch(pattern)(getOrigin(url) || url);
- }
- const host = getHost(url);
- if (!host) {
- return false;
- }
- // For host-only wildcards, match just the host
- return wildcardMatch(pattern)(host);
- }
- const protocol = getProtocol(url);
- return protocol === "http:" || protocol === "https:" || !protocol
- ? pattern === getOrigin(url)
- : url.startsWith(pattern);
- };
-
const validateURL = (url: string | undefined, label: string) => {
if (!url) {
return;
}
- const isTrustedOrigin = trustedOrigins.some(
- (origin) =>
- matchesPattern(url, origin) ||
- (url?.startsWith("/") &&
- label !== "origin" &&
- /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(
- url,
- )),
- );
+ const isTrustedOrigin = ctx.context.isTrustedOrigin(url, {
+ allowRelativePaths: label !== "origin",
+ });
+
if (!isTrustedOrigin) {
ctx.context.logger.error(`Invalid ${label}: ${url}`);
ctx.context.logger.info(
`If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`,
- `Current list of trustedOrigins: ${trustedOrigins}`,
+ `Current list of trustedOrigins: ${ctx.context.trustedOrigins}`,
);
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
diff --git a/packages/better-auth/src/auth/trusted-origins.test.ts b/packages/better-auth/src/auth/trusted-origins.test.ts
new file mode 100644
index 0000000000..c019b076ba
--- /dev/null
+++ b/packages/better-auth/src/auth/trusted-origins.test.ts
@@ -0,0 +1,289 @@
+import { createAuthEndpoint } from "@better-auth/core/api";
+import { describe, expect, it } from "vitest";
+import * as z from "zod";
+import { getTestInstance } from "../test-utils";
+import type { BetterAuthOptions } from "../types";
+
+async function createAuthTestInstance(overrides?: Partial) {
+ const testServerPlugin = {
+ id: "test-plugin",
+ endpoints: {
+ testTrustedOrigin: createAuthEndpoint(
+ "/test-trusted-origin",
+ {
+ method: "GET",
+ query: z.object({
+ url: z.string(),
+ allowRelativePaths: z.coerce.boolean().optional(),
+ }),
+ },
+ async (ctx) => {
+ const settings =
+ typeof ctx.query.allowRelativePaths === "boolean"
+ ? { allowRelativePaths: ctx.query.allowRelativePaths }
+ : undefined;
+ return ctx.context.isTrustedOrigin(ctx.query.url, settings);
+ },
+ ),
+ },
+ };
+
+ const testClientPlugin = {
+ id: "test-client-plugin",
+ $InferServerPlugin: {} as typeof testServerPlugin,
+ };
+
+ const { auth, client } = await getTestInstance(
+ {
+ plugins: [testServerPlugin],
+ ...overrides,
+ },
+ { clientOptions: { plugins: [testClientPlugin] } },
+ );
+
+ const isTrustedOrigin = async (
+ url: string,
+ settings?: { allowRelativePaths: boolean },
+ ) => {
+ const result = await client.testTrustedOrigin({
+ query: { url, ...settings },
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ return result.data;
+ };
+
+ return { auth, client, isTrustedOrigin };
+}
+
+describe("trusted origins", () => {
+ describe("trusted origins list support", () => {
+ it("should always allow the app's origin", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ await expect(isTrustedOrigin("http://localhost:3000")).resolves.toBe(
+ true,
+ );
+
+ await expect(
+ isTrustedOrigin("http://localhost:3000/some/path"),
+ ).resolves.toBe(true);
+ });
+
+ it("should reject origins that start with a trusted origin", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["https://trusted.com"],
+ });
+
+ await expect(
+ isTrustedOrigin("https://trusted.com.malicious.com"),
+ ).resolves.toBe(false);
+ });
+
+ it("should reject untrusted origin subdomains", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["https://trusted.com"],
+ });
+
+ await expect(
+ isTrustedOrigin("http://sub-domain.trusted.com"),
+ ).resolves.toBe(false);
+ });
+
+ it("should allow origins that directly match a trusted origin", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["https://trusted.com"],
+ });
+
+ await expect(isTrustedOrigin("https://trusted.com")).resolves.toBe(true);
+
+ await expect(
+ isTrustedOrigin("https://trusted.com/some/path"),
+ ).resolves.toBe(true);
+ });
+ });
+
+ describe("relative paths support", () => {
+ it("should reject relative paths by default", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ await expect(isTrustedOrigin("/")).resolves.toBe(false);
+ await expect(isTrustedOrigin("/some-absolute-url")).resolves.toBe(false);
+ });
+
+ it("should allow relative paths", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ try {
+ await isTrustedOrigin("/", { allowRelativePaths: true });
+ } catch (error) {
+ console.log(error);
+ }
+
+ await expect(
+ isTrustedOrigin("/", { allowRelativePaths: true }),
+ ).resolves.toBe(true);
+ await expect(
+ isTrustedOrigin("/dashboard", { allowRelativePaths: true }),
+ ).resolves.toBe(true);
+ });
+
+ it("should allow relative paths with query params", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ await expect(
+ isTrustedOrigin("/dashboard?email=123@email.com", {
+ allowRelativePaths: true,
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("should allow relative paths with plus signs", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ await expect(
+ isTrustedOrigin("/dashboard+page?test=123+456", {
+ allowRelativePaths: true,
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("should reject urls with double dash", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ await expect(
+ isTrustedOrigin("//evil.com", { allowRelativePaths: true }),
+ ).resolves.toBe(false);
+ });
+
+ it("should reject urls with encoded malicious content", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance();
+
+ const maliciousPatterns = [
+ "/%5C/evil.com",
+ `/\\/\\/evil.com`,
+ "/%5C/evil.com",
+ "/..%2F..%2Fevil.com",
+ "javascript:alert('xss')",
+ "data:text/html,",
+ ];
+
+ for (const pattern of maliciousPatterns) {
+ await expect(
+ isTrustedOrigin(pattern, { allowRelativePaths: true }),
+ ).resolves.toBe(false);
+ }
+ });
+ });
+
+ describe("wildcards support", () => {
+ it("should allow origins that match a wildcard trusted origin", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["*.my-site.com"],
+ });
+
+ await expect(
+ isTrustedOrigin("https://sub-domain.my-site.com"),
+ ).resolves.toBe(true);
+
+ await expect(
+ isTrustedOrigin("https://sub-domain.my-site.com/callback"),
+ ).resolves.toBe(true);
+
+ await expect(
+ isTrustedOrigin("https://another-sub.my-site.com"),
+ ).resolves.toBe(true);
+
+ await expect(
+ isTrustedOrigin("https://another-sub.my-site.com/callback"),
+ ).resolves.toBe(true);
+ });
+
+ it("should reject urls with malicious domain with wildcard trusted origins", async (ctx) => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["*.example.com"],
+ });
+ await expect(isTrustedOrigin("malicious.com?.example.com")).resolves.toBe(
+ false,
+ );
+ });
+
+ it("should work with protocol specific wildcard trusted origins", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: ["https://*.protocol-site.com"],
+ });
+
+ // Test HTTPS protocol specific wildcard - should work
+ await expect(
+ isTrustedOrigin("https://api.protocol-site.com"),
+ ).resolves.toBe(true);
+
+ // Test HTTP with HTTPS protocol wildcard - should fail
+ await expect(
+ isTrustedOrigin("http://api.protocol-site.com"),
+ ).resolves.toBe(false);
+ });
+
+ it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: [
+ "exp://10.0.0.*:*/*",
+ "exp://192.168.*.*:*/*",
+ "exp://172.*.*.*:*/*",
+ ],
+ });
+
+ // Test with IP matching the wildcard pattern
+ await expect(isTrustedOrigin("exp://10.0.0.29:8081/--/")).resolves.toBe(
+ true,
+ );
+
+ // Test with different IP range that matches
+ await expect(
+ isTrustedOrigin("exp://192.168.1.100:8081/--/"),
+ ).resolves.toBe(true);
+
+ // Test with different IP range that matches
+ await expect(isTrustedOrigin("exp://172.16.0.1:8081/--/")).resolves.toBe(
+ true,
+ );
+
+ // Test with IP that doesn't match any pattern - should fail
+ await expect(isTrustedOrigin("exp://203.0.113.0:8081/--/")).resolves.toBe(
+ false,
+ );
+ });
+ });
+
+ describe("dynamic trusted origins", () => {
+ it("should allow dynamically computed trusted origins", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: async (request) => {
+ const url = new URL(
+ new URL(request.url).searchParams.get("url") ?? "unknown",
+ );
+ return [url.origin];
+ },
+ });
+
+ await expect(
+ isTrustedOrigin("http://localhost:5000/callback"),
+ ).resolves.toBe(true);
+ });
+
+ it("should not allow dynamically computed trusted origins", async () => {
+ const { isTrustedOrigin } = await createAuthTestInstance({
+ trustedOrigins: async (request) => {
+ return []; // no additional trusted origins
+ },
+ });
+
+ await expect(
+ isTrustedOrigin("http://localhost:5000/callback"),
+ ).resolves.toBe(false);
+ });
+ });
+});
diff --git a/packages/better-auth/src/auth/trusted-origins.ts b/packages/better-auth/src/auth/trusted-origins.ts
new file mode 100644
index 0000000000..fea9e5c097
--- /dev/null
+++ b/packages/better-auth/src/auth/trusted-origins.ts
@@ -0,0 +1,45 @@
+import { getHost, getOrigin, getProtocol } from "../utils/url";
+import { wildcardMatch } from "../utils/wildcard";
+
+/**
+ * Matches the given url against an origin or origin pattern
+ * See "options.trustedOrigins" for details of supported patterns
+ *
+ * @param url The url to test
+ * @param pattern The origin pattern
+ * @param [settings] Specify supported pattern matching settings
+ * @returns {boolean} true if the URL matches the origin pattern, false otherwise.
+ */
+export const matchesOriginPattern = (
+ url: string,
+ pattern: string,
+ settings?: { allowRelativePaths: boolean },
+): boolean => {
+ if (url.startsWith("/")) {
+ if (settings?.allowRelativePaths) {
+ return (
+ url.startsWith("/") &&
+ /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url)
+ );
+ }
+
+ return false;
+ }
+
+ if (pattern.includes("*")) {
+ // For protocol-specific wildcards, match the full origin
+ if (pattern.includes("://")) {
+ return wildcardMatch(pattern)(getOrigin(url) || url);
+ }
+ const host = getHost(url);
+ if (!host) {
+ return false;
+ }
+ // For host-only wildcards, match just the host
+ return wildcardMatch(pattern)(host);
+ }
+ const protocol = getProtocol(url);
+ return protocol === "http:" || protocol === "https:" || !protocol
+ ? pattern === getOrigin(url)
+ : url.startsWith(pattern);
+};
diff --git a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
index a19603f8bb..01ed2159f7 100644
--- a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
+++ b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
@@ -112,6 +112,7 @@ exports[`base context creation > should match config 1`] = `
"updateUserByEmail": [Function],
"updateVerificationValue": [Function],
},
+ "isTrustedOrigin": [Function],
"logger": {
"debug": [Function],
"error": [Function],
diff --git a/packages/better-auth/src/context/create-context.ts b/packages/better-auth/src/context/create-context.ts
index dda6e62db8..58865b1f6e 100644
--- a/packages/better-auth/src/context/create-context.ts
+++ b/packages/better-auth/src/context/create-context.ts
@@ -10,6 +10,7 @@ import { createTelemetry } from "@better-auth/telemetry";
import defu from "defu";
import type { Entries } from "type-fest";
import { checkEndpointConflicts } from "../api";
+import { matchesOriginPattern } from "../auth/trusted-origins";
import { createCookieGetter, getCookies } from "../cookies";
import { hashPassword, verifyPassword } from "../crypto/password";
import { createInternalAdapter } from "../db/internal-adapter";
@@ -174,6 +175,11 @@ export async function createAuthContext(
},
tables,
trustedOrigins: getTrustedOrigins(options),
+ isTrustedOrigin(url: string, settings?: { allowRelativePaths: boolean }) {
+ return ctx.trustedOrigins.some((origin) =>
+ matchesOriginPattern(url, origin, settings),
+ );
+ },
baseURL: baseURL || "",
sessionConfig: {
updateAge:
diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts
index ceb7d3f06c..34345e3e5c 100644
--- a/packages/core/src/types/context.ts
+++ b/packages/core/src/types/context.ts
@@ -165,6 +165,16 @@ export type AuthContext =
appName: string;
baseURL: string;
trustedOrigins: string[];
+ /**
+ * Verifies whether url is a trusted origin according to the "trustedOrigins" configuration
+ * @param url The url to verify against the "trustedOrigins" configuration
+ * @param settings Specify supported pattern matching settings
+ * @returns {boolean} true if the URL matches the origin pattern, false otherwise.
+ */
+ isTrustedOrigin: (
+ url: string,
+ settings?: { allowRelativePaths: boolean },
+ ) => boolean;
oauthConfig: {
/**
* This is dangerous and should only be used in dev or staging environments.