About Author
A formerly masked, now weaponized ASD-1 nihilist with an well-honed hatred for bullshit, who architects software systems just enough to fund a life spent exposing nepo-babies, working on taxing billionaires into extinction, and teaching everyone with a paycheck that their soul isn't a subscription.
I have configured a lot of Auth0 tenants in my life, and I want to be fair to every one of them before I explain why this project doesn’t have one. Auth0 is a service. You configure a tenant, define connections and rules in a dashboard, redirect users to a hosted page, and validate JWTs against a JWKS endpoint. Identity lives outside your system and you hold a token that vouches for it. It is a perfectly respectable arrangement, and for a decade it was the arrangement I reached for without thinking, which is exactly the kind of reaching this series keeps catching me doing.
This is part 7 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.
better-auth is a library. It runs in your process, writes to your database, sets its own cookies, and there is no third party. That sentence sounds small and it is not. The entire mental shift—the one this post is really about—is that identity is now your data with your schema in your transaction scope. Nobody is vouching for anything. There’s no embassy to phone. There’s just a table, and you own the table.
graph LR
subgraph Auth0
A[Browser] -->|redirect| B[Auth0 hosted page]
B -->|code| C[Your API]
C -->|validate JWT| D[JWKS endpoint]
C --> E[(Your DB<br/>user profile copy)]
end
subgraph "better-auth"
F[Browser] -->|POST /api/auth/sign-in| G[Your API<br/>auth.handler]
G --> H[(Your DB<br/>user, session, account)]
end
What you gain
Sessions are rows you can join against. In this system a user has a system-generated handle, a phone number, a membership state, and a place on an orbit roster. With Auth0 the identity lives elsewhere and I’d keep a shadow copy synced by webhook—a sync that can lag, fail, or drift, and I have debugged every one of those verbs at previous jobs. Here user.handle is a column with a unique index, and a foreign key away from everything else. The shadow copy problem doesn’t get solved; it gets deleted.
No network hop on the hot path. getSession is a database read in the same isolate. No JWKS fetch, no token introspection, no cache to invalidate.
Hooks run inside your request. The sign-up hook mints the handle, normalizes the phone to E.164, checks uniqueness, and parks a pending invite—before the user row is written:
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email') return;
const phone = normalizePhone(body.phoneNumber ?? body.phone);
if (!phone) throw new APIError('BAD_REQUEST', { message: 'A valid phone number is required' });
// Handle is always system-generated—never trust a client-supplied value.
const handle = await mintHandle(db);
return { context: { body: { ...rest, phoneNumber: phone, handle, name: handle } } };
}),
}
On Auth0 this is a Rule or Action—a separate JavaScript sandbox, separately deployed, with its own logs, that can’t easily query my database. Here it’s a function with a live db. I cannot overstate how much of my past professional life has been spent shuttling context between a Rule sandbox and the application it was nominally part of.
Composability. The config is a list of plugins:
plugins: [
...(opts?.skipCaptcha ? [] : [captcha({ provider: 'cloudflare-turnstile', … })]),
phoneNumber({ otpLength: 6, expiresIn: 300, sendOTP: async ({ phoneNumber, code }) => … }),
expo(),
bearer(),
]
That conditional captcha is worth pausing on. An attested first-party mobile app skips Turnstile because hardware attestation is the bot gate instead—so the auth instance is built per request with a different plugin set. Try expressing “this tenant has a different rule set for one class of client, decided per request” in Auth0. I’ll wait, but I’ll wait smugly.
What you lose
Nobody else is thinking about your auth. Auth0 ships breach-password detection, anomaly detection, MFA, bot protection, and compliance attestations. I now own all of that. For this project—anonymous handles, no real names, phone + email only—the trade is right. For a fintech it wouldn’t be, and I’d like that sentence on the record before anyone quotes this post at their security review.
No hosted UI. Every form is mine, including the error states.
No dashboard. Revoking a session is a SQL statement.
You must understand the security model, because you’re operating it. Which brings us to the actual content of this post, which is me failing to understand the security model three times in a row, in three instructively different ways.
Three 403s
The mobile app couldn’t reach local dev
Running the app against wrangler pages dev, every authenticated request failed immediately. The response:
{"message":"Invalid origin","code":"INVALID_ORIGIN"}
Reading better-auth’s source (a thing you can do, which is itself the point):
// node_modules/better-auth/dist/api/middlewares/origin-check.mjs
if (!trustedOrigins.some((origin) => matchesOriginPattern(originHeader, origin))) {
ctx.context.logger.error(`Invalid origin: ${originHeader}`);
throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN);
}
trustedOrigins is built from env.BETTER_AUTH_URL plus a few literals. That was http://localhost:8788. A phone can’t reach localhost, so it called the LAN IP—an origin nobody trusted. The library was doing precisely its job; I had simply never told it the truth about where requests would come from.
The fix isn’t code, it’s the dev command:
wrangler pages dev packages/rorbt-ui/dist --ip 0.0.0.0 --port 8788 \
--binding BETTER_AUTH_URL=http://192.168.1.50:8788
--ip 0.0.0.0 because pages dev binds loopback by default (that’s a connection-refused, a different symptom entirely), and the --binding override because BETTER_AUTH_URL does double duty as both baseURL and a trusted origin.
Reproduced it to be sure, which is the part I’d recommend to anyone debugging CSRF-ish failures:
POST /api/auth/sign-out Origin: http://192.168.188.109:8791
→ 403 {"message":"Invalid origin","code":"INVALID_ORIGIN"}
A fix you haven’t reproduced from both sides is a fix you’re taking on faith, and faith is exactly the thing we gave up when we gave up the identity provider.
MISSING_OR_NULL_ORIGIN
A second failure mode, different code:
{"message":"Missing or null Origin","code":"MISSING_OR_NULL_ORIGIN"}
Bare React Native fetch sends no Origin header at all. better-auth’s check only runs when cookies are present—which they are—and an absent origin is treated as hostile. Which is the correct default, even when it’s happening to you.
The @better-auth/expo plugin handles this by sending expo-origin, which the server plugin copies into origin:
// @better-auth/expo—server side
if (options?.disableOriginOverride || request.headers.get("origin")) return;
const expoOrigin = request.headers.get("expo-origin");
if (expoOrigin) newHeaders.set("origin", expoOrigin);
Verified: expo-origin: rorbtmobile:// passes. If you call the API with plain fetch instead of the expo client, you must set that header yourself.
Preview deployments—and a security hole
This one wasn’t a dev-loop annoyance, and it’s the reason this post exists. trustedOrigins contained:
'https://rorbt.pages.dev',
The Pages project is named rorbt-site. Its deployments land on rorbt-site.pages.dev, and branch deploys on <hash>.rorbt-site.pages.dev. So:
- Every preview deployment failed the origin check. Expected, once you see it.
rorbt.pages.devis a project we don’t own. Anyone can register an unclaimed Pages project name and receive that hostname—at which point the config was granting their origin credentialed access to the API.
An allowlist with a wrong entry isn’t a smaller allowlist. It’s an invitation addressed to whoever claims the name first.
The same wrong literal was in the CORS helper:
if (hostname === 'rorbt.pages.dev' || hostname.endsWith('.rorbt.pages.dev')) return origin;
Fixed on both sides. better-auth supports wildcards natively—I checked the matcher before relying on it:
// better-auth/dist/auth/trusted-origins.mjs
if (pattern.includes("*") || pattern.includes("?")) {
if (pattern.includes("://")) return wildcardMatch(pattern)(getOrigin(url) || url);
…
}
trustedOrigins: [
env.BETTER_AUTH_URL,
'https://rorbt-site.pages.dev',
'https://*.rorbt-site.pages.dev', // per-deployment previews
'http://localhost:4321',
'http://localhost:8788',
'rorbtmobile://',
],
And CORS, with an extra condition:
const { hostname, protocol } = new URL(origin);
// Preview deployments are always https; refusing http here stops an
// attacker-controlled plaintext origin from matching the suffix test.
if (protocol === 'https:' && (hostname === PAGES_HOST || hostname.endsWith(`.${PAGES_HOST}`)))
return origin;
Seven tests, including suffix-smuggling (rorbt-site.pages.dev.evil.com) and the plaintext lookalike. The tests took twenty minutes. The vulnerability had been sitting in the repo for weeks, agreed with warmly by every code review it passed through.
The secure cookie trap
One config line worth understanding before it costs you an afternoon, because it cost me one:
const isHttps = env.BETTER_AUTH_URL.startsWith('https://');
advanced: { defaultCookieAttributes: { sameSite: 'lax', httpOnly: true, secure: isHttps } },
If BETTER_AUTH_URL says https:// but you’re serving over plain HTTP locally, better-auth marks session cookies Secure, the browser silently refuses to store them, and you never stay logged in—with no error anywhere. Not a 401, not a console warning. Just a session that never persists.
The failures that cost afternoons aren’t the ones that throw. They’re the ones where two components each behave correctly and the correctness cancels out.
This is the concrete reason not to point a local server at a real https:// hostname via a hosts entry. (*.pages.dev also sends HSTS, so your browser would refuse plain HTTP to that host afterwards, semi-permanently.) If you want a stable local hostname, use .localhost—it resolves to 127.0.0.1 with no DNS config, and it’s honestly http://.
Operating your own auth: what I actually had to know
Things Auth0 would have owned, which I now own:
| Concern | Where it lives now |
|---|---|
| Session token signing | BETTER_AUTH_SECRET—must be unique per environment, or preview sessions validate in production |
| Rate limiting sign-in | rateLimit: { window: 60, max: 10 }, with a tighter budget for SMS OTP |
| Bot protection | Turnstile plugin, bypassed only for hardware-attested clients |
| Email verification | requireEmailVerification: true + my own send function |
| Password policy | minPasswordLength: 16 |
| Session expiry | expiresIn: 7d, updateAge: 1d |
| CSRF / origin | trustedOrigins—see above, twice |
| Account deletion | deleteUser: { enabled: true }—and the cascade is mine (post 09) |
That table looks like a burden, and some days it is. But the SMS budget is a good example of owning your own auth being better, not just cheaper:
customRules: {
// get-session is the cheap, idempotent "am I still authed?" probe.
'/get-session': { window: 60, max: 120 },
// SMS costs money and is abusable (toll-fraud / OTP bombing).
'/phone-number/send-otp': { window: 60, max: 3 },
}
Per-endpoint budgets reflecting the actual cost and abuse profile of each route. Auth0 gives you a global anomaly detector; this is three lines and it’s exactly right.
Would I do it again?
For this project, yes, without hesitation. Identity is deeply entangled with domain logic here—handles are minted by the app, membership state lives on an orbit roster, and deleting an account has to cascade through a hash-chained audit log. Every one of those would have been a sync problem with an external identity provider, and I have lost enough of my life to sync problems to know exactly what I was declining.
The bar to clear: you have to actually read the library’s source. I read better-auth’s origin-check, its trusted-origin matcher, and the expo plugin’s header handling—all in one day. That’s not a complaint; it’s the deal, and it’s a better deal than the one where the security model lives behind someone else’s dashboard and you find out what it does when it bills you.
A library you operate is a library you must understand. The service was never sparing you the understanding—only deferring the invoice.
Previous: Testing Workers Against Real Bindings · Next: Astro After Next.js
Related
Six Security Findings from One Day of Code Review
One day of manual review on a Cloudflare governance platform: a CORS allowlist trusting an unclaimed pages.dev domain, hashed low-entropy tags posing as anonymization, a scalar trust score, GDPR erasure vs. an append-only hash chain, an HMAC unsubscribe token, and PII in logs. A scanner found none of them.
Observability on the Edge
Retrofitting structured, request-correlated logging onto Cloudflare Pages Functions: why winston won't run in workerd and pino loses its speed there, how AsyncLocalStorage carries request ids through 58 orphaned console.error calls without touching a signature, and a request-id scheme that survives system boundaries.
Testing Workers Against Real Bindings
Getting @cloudflare/vitest-pool-workers running tests inside workerd against real D1 and R2: an ESM-only config trap, per-file (not per-test) storage isolation and the resetStorage that fixes it, seeding real better-auth sessions, and the rate limiter that fought back.