"We built our own auth" is a sentence that usually makes senior engineers wince. It has earned that. For twenty years it's been shorthand for a team that underestimated the problem, shipped a login form, and found out about session fixation from a security researcher.
We built our own auth. I don't think we were reckless, and the reason isn't that we're smarter than the people who got burned. It's that four things have to be true before this is a sane decision, and for the first time in a while, all four were true for us at once.
This is the write-up. What we needed, why the platforms couldn't give it to us, what we brought to the table, and the decisions that followed. The system is called re-auth, and it's the identity layer under everything we ship.
Target reader: staff engineers and architects picking an identity strategy for a multi-product company.
Prerequisites: OAuth 2.0 and OIDC vocabulary, multi-tenancy basics, and a feel for what a serverless runtime does and doesn't give you. We don't teach OIDC here.
By Swapnil Kulshreshtha, Founding Engineer at Reveriext. An engineering write-up on the decisions behind re-auth, our multi-tenant identity system, built on better-auth, Cloudflare Workers, and D1.
Why we needed our own
There's a moment in every serious software company where someone says the words "we need our own auth." What they mean is rarely simple. For us it meant login, yes, but also single sign-on for the enterprise deals in the pipeline, passkeys because a security review asked for them, per-customer branding down to the login card, session policies the compliance team could change without a deploy, service tokens for the AI agents that were starting to outnumber the humans, and a cost that didn't scale with a number some vendor got to pick.
But the feature list wasn't the hard part. The hard part was the shape of what we're building.
Our portfolio isn't five copies of the same app. Humanely is a company operating system, and inside it sit a set of business-function apps that each behave like their own product: Lens, Compliance, HR, Recruit, Payroll, Books, plus supporting apps for tasks, documents, and concierge. A recruiter lives in Recruit all day and may never open Books. A founder lives in Books and Compliance. They're in the same instance of the same product, with different reasons to be there.
Alongside that sits Humanely Me, which is person-facing rather than company-facing. A person has a working life that outlasts any one employer, so their identity there belongs to them and not to a company. Then there are standalone products: Kin, our CRM. Keep, for delivery management. WE8, a small website management system we're building. Different products, different customers, same identity questions.
Put that in front of an identity layer and you get three separate things that all get called "identity," and conflating any two of them will hurt you later.
The person. One human, one identity, portable across every company they work with. If someone changes employer, they shouldn't lose their account, and their new employer shouldn't inherit anything from the old one.
The organization. The workspace a company operates in. Members belong to it, data lives inside it, and one person can legitimately belong to several. This is the boundary that has to be airtight, because a leak across it is a leak between companies.
The app. Which surface a person is entitled to, inside a workspace. Recruit and Books are different apps under one company operating system, and access to one doesn't imply access to the other. A standalone product like Kin or Keep is the same question wearing different clothes.
Diagram: the three levels. One person, several organizations, and a different set of entitled apps in each. Apps under a company operating system (Recruit, Books, Compliance) and standalone products (Kin, Keep, WE8) sit at the same level, because from identity's point of view they're the same kind of thing.
Read that diagram as a product spec and it's a feature. Read it as a threat model and it's the whole problem. The person axis has to cross organizations, because that's the point of a portable identity. The organization axis must never cross, because that's a data breach. Those two requirements pull in opposite directions, and every serious decision in this post is downstream of holding both at once.
That's also why we couldn't treat this as five apps each doing their own login. Every security fix was being written five times. Every login screen behaved slightly differently, in ways nobody had decided on. And nothing in that arrangement had an opinion about what happens when one person belongs to two companies.
Why the platforms couldn't do it
We looked hard at the major managed identity providers. This wasn't a formality. If one of them had fit, we'd have signed and moved on to work that's closer to what our customers pay us for.
They're genuinely good at the 80% that looks the same at every company. Our problem was the last 20%, which for us is the product.
Their tenancy model wasn't ours. Their theming stopped a layer above where we needed it, and per-customer isolation of the kind we wanted tends to sit behind an enterprise tier. Those are all solvable with money, in theory. What isn't solvable with money is the roadmap. We'd be asking a vendor to care about our tenancy shape as much as we do, and no vendor ever will, because their job is the 80%.
Then there's the meter. Monthly active users, then machine identities, then SSO connections billed per organization. We're building products where AI agents hold their own identities, so the "machine identity" line item isn't a rounding error for us. It's a growth curve we'd be renting at somebody else's price.
Saying that out loud is what cracked the problem open. The meter and the ceiling were the real objections. Not the login box. And once you know the objection is about control rather than capability, "buy" stops being the safe default it looks like.
What changed
Here's the part that I think is genuinely different now, and the reason this piece exists.
Building auth from scratch has always been a security minefield disguised as a weekend project. The happy path really is a weekend: email, password, a session cookie. It's everything after the happy path that ends careers. Constant-time comparisons. PKCE. Refresh-token rotation. JWKS key management and rollover. OIDC conformance. The difference between a session that's revoked and one that has merely expired.
That list hasn't gotten shorter. What's changed is that you no longer have to write any of it.
The open-source protocol cores available today are correct, audited, and maintained by people who do nothing else. We use better-auth: self-hosted, TypeScript-native, with sessions, an OIDC provider, an admin plugin, PKCE, and plugin surfaces for passkeys and organizations already there. It isn't the story of this post, and that's exactly the point. It's a dependency we import and don't think about, the way you don't think about your TLS library.
We looked at the heavier all-in-one self-hosted identity servers too. Any of them could do the job. We ruled them out for the same reason every time: they want to be the center of your architecture. A standalone service, its own database, its own deployment topology. We needed something that sits inside our runtime as a library and then gets out of the way. better-auth is code we import, not infrastructure we operate. That was what decided it.
So the cryptography stopped being the hard part. Which means the question changes from "can you get auth right" to "can you run the thing around it." Four conditions have to hold.
You've built systems this complicated before. Our practitioner-architects have spent years on identity and access at a scale where mistakes are public. At earlier firms, that included the platform New York City used to manage citywide vaccination for roughly 8.4 million people, a voter management system carrying 12 million active voters and 1,300-plus state and county staff, and a federal grants program that disbursed $1.074 billion to more than 18,000 providers with bank and business verification in the path. Those weren't Reveriext contracts and I won't claim them as such. But the scar tissue came with the team, and scar tissue is the thing you can't buy or read your way into. When we argued about isolation models, we were arguing from having watched what happens when you get it wrong.
You know exactly what you're building. We spent real time studying our own requirements before writing anything, and the output was a short list of things we needed that vendors don't do, plus a much longer list of things we deliberately weren't building. Scope discipline is most of the safety here. Teams that get hurt building auth are teams that kept discovering new requirements.
The protocol core is somebody else's job. Covered above. This is the one that changed.
The platform removes the operations. This is the condition people underrate, and it's why the rest of this post is so specific about Cloudflare. Running your own identity layer used to mean running servers, patching them, replicating a database across regions, keeping a cron daemon alive, and being on call for all of it. On Workers, D1, KV, and Durable Objects, most of that isn't a job anymore. The platform does the replication and the scheduling, and the constraints it imposes turned out to make the system safer rather than harder. A small team can genuinely operate this.
Take one of those away and I'd give different advice. Without the scar tissue, you don't know which corners are load-bearing. Without a scoped problem, you'll keep building forever. Without a correct core, you're back in the minefield. Without the platform, you've signed up for an on-call rotation you didn't budget for.
We had all four. So we built it.
The runtime
Wrapping a library still leaves the question of what it runs on. Our first instinct was the boring answer: a long-lived service, a managed database, a container platform. It's what everyone knows how to operate.
We talked ourselves out of it. Identity is the one thing that has to be close to every request, in every region, with the lowest tail latency you can manage. A login that stalls is a login people abandon. A container in one region behind a load balancer is the opposite of that.
So we went serverless-only, on Cloudflare Workers, and took the constraints that come with it. No SMTP. No long-lived processes. No daemons. No background threads.
Those constraints turned out to be useful rather than annoying:
Transactional email can't leave a Worker over SMTP, so it goes over an HTTP email API. We send over Cloudflare Email, which keeps sending on the same platform as everything else and lets a single account send from several verified domains, with Resend kept wired as a fallback.
There's no Node crypto, so everything is Web Crypto and
jose. Modern, audited primitives, and no temptation to reach for something older because it was easier to import.Scheduled work can't be a cron daemon, so it's Cron Triggers and Durable Object alarms. Multi-tenant by nature, no machine kept alive.
State lives in D1 (SQLite at the edge) and KV, both replicated for us by the platform.
The best part isn't the latency, though. A Worker has no ambient environment. No local disk, no daemon, nothing to SSH into. Every input and every side effect is a binding you declared, which means you can sit down and list every single thing the service is able to touch. For something security-sensitive, that's worth more than it sounds.
Where we landed: Cloudflare Workers, serverless-only. Email over Cloudflare Email (HTTP), Resend as a fallback; crypto via Web Crypto and
jose; schedules via Cron Triggers and DO alarms; state in D1 and KV. The constraints became the architecture.
Tenancy, or how tenants are actually kept apart
This is the one we argued about longest, because it's the one you can't cheaply undo.
Most products start in the same place: one shared database, a tenant_id column on every table, and the discipline to always filter by it. We rejected it on a single observation. That discipline fails exactly once, silently, and when it fails it's a cross-tenant data leak. Every new query is a chance to forget. Every join. Every clever reporting endpoint somebody adds on a Friday. With identity data, where a leak is both catastrophic and unforgivable, we didn't want safety to depend on remembering.
Fig.1: Isolation models and blast radius. Three models compared. Shared table with tenant_id: one pool of users, where one forgotten filter reaches everyone. Schema per tenant: split cells in a shared cluster, so a shared cluster means a shared blast radius. Database per account: separate databases, where a leak (red) stops at one account and the rest carry on. What we chose: the right-hand model. The isolation is structural, not procedural. There's no shared table to forget to filter, so the worst case is one account.
The middle option, schema-per-tenant in a shared cluster, moves the boundary but keeps the blast radius. One overloaded query or one bad migration and everybody feels it. We wanted the boundary to be physical.
So we went to the far end of the spectrum, which Cloudflare makes practical: a database per account and a dispatch worker per account, on Workers for Platforms. Every customer account gets its own D1 database and its own isolated worker. Tenant A's users can't show up in Tenant B's results, because there's no shared table to forget to filter.
This is the clearest example of the fourth condition doing work. Database-per-account is the isolation model most teams want and almost nobody chooses, because provisioning and operating thousands of databases is a platform team's full-time job. On Workers for Platforms it's an API call.
This is the organization axis from the three-level model, and it's now a physical boundary rather than a rule. One account, one database, one worker.
Inside an account there's still structure to manage, so we added two more axes. A projectId scopes every top-level entity for the apps under one account, which is the app axis: Recruit and Books are separate projects under one company's account, and so is Kin if that company uses it. And environments (staging, production, development) are separate databases and separate deployments, not a column, so a staging mistake can't reach a real user.
The person axis is the one that deliberately doesn't live here, because it's the one thing that has to cross accounts. That's what makes cross-tenant sign-on the hardest problem in the system, and it's why it's still open. More on that below. At the edge, an HMAC envelope plus an active-org-versus-subdomain gate returns a flat 404 to anything that doesn't line up. Point at the wrong tenant and you don't get an error confirming it exists. You get nothing.
Diagram: the request path behind Fig. 1's right-hand model. Two gates run before any account code does, and both failure modes return the same flat 404. The globex database is drawn unreachable on purpose. There's no code path from this request to it. We chose isolation you can't forget to apply, and paid for it in operational machinery we couldn't skip.
That's the honest trade. Database-per-account turns "just add a column" into a fleet migration that has to reach every account's D1. It turns provisioning into a saga with retries and compensations. We took all of that, because the alternative was betting our most sensitive data on nobody ever forgetting a WHERE clause.
Where we landed: Database-per-account on Workers for Platforms,
projectIdas a second axis, environments as separate deployments. Structural isolation over procedural discipline, and we built the migration and provisioning machinery that price demands.
Configuration, or whether a policy change is a deploy
Here's a test we started applying to every setting. If changing it for one customer needs a pull request, it isn't configuration. It's a hardcoded default with extra steps.
This is where extending the core mattered most. A protocol library gives you correct sessions. It doesn't give you a way for an operator to change one customer's session policy at four in the afternoon without shipping code. A managed vendor hands you a dashboard for that for free, and it would have been easy to leave durations and providers and branding as environment variables and constants, then "configure" a customer by editing code. We'd have paid for that shortcut every single time somebody wanted their session timeout changed or their login card rebranded.
So we made a rule and stuck to it. Anything an operator might tune lives in the database and gets edited in a console. Never in the source. Token TTLs, enabled providers, whether self-signup is open or invite-only, the login card's brand name and accent and uploaded logo, per-tenant email copy, entitlements. The running worker reads it cache-warmed on the hot path, so none of that flexibility costs anything at request time. Shipping a new capability now means shipping a new knob, not a new deploy for every customer who wants it set differently.
Where we landed: Runtime config as data, edited in a console, cache-warmed on the hot path. If an operator might change it, it's never a constant. New features arrive as knobs, not deploys.
Security posture, or the parts the core leaves to you
Better-auth gets the protocol right. Building on it meant owning the decisions it deliberately leaves open, and holding each one to a production bar instead of a demo bar.
Tokens at rest. The question isn't whether your database is encrypted. It's whether a stolen backup is useful. We encrypt third-party tokens, and hash session and grant material instead of storing it raw, so a leaked snapshot is inert instead of a master key.
Revocation, done properly. We treated this as its own subsystem, because expiry is not revocation.
Expiry is a promise that access ends eventually. Revocation is a guarantee that it ends now.
Fig. 2: revocation, expiry window versus event fan-out. Two lanes. Short-TTL, expiry only: access continues until the token TTL elapses, so it lingers for the whole token lifetime. Event pipeline: a ban (dark square) fans out across the fleet of account nodes (teal) in seconds, and access ends. What we chose: the event pipeline. Access ends when we say it ends, without charging every healthy request for a central check.
The cheap answer is very short token lifetimes and hoping the window is small enough. We didn't take it. Short TTLs punish every login to soften one edge case, and there's still a gap. The other extreme, checking a central authority on every request, puts back the exact latency and single point of failure we moved to the edge to avoid. So we took the middle: an event-driven revocation pipeline, where banning a user or killing a grant emits an event that fans out across the fleet in seconds, with a reconcile pass as a backstop.
Bots at the front door. The endpoints that start a flow (sign-up, sign-in, one-time-code sends) are where automated abuse and credential stuffing arrive first. Those specific paths sit behind a CAPTCHA challenge (Cloudflare Turnstile), verified on the server rather than trusted from the client, and switched on per tenant so a product that needs it is protected without taxing one that doesn't.
Two smaller calls we made non-negotiable:
PKCE is enforced, not offered. An optional security control that clients can skip is one that some client will skip. So the server requires it.
SSO-asserted email is bound to the connection's verified domain. An identity provider can only assert users in a domain it has proven it owns. That shuts the door on a misconfigured or malicious IdP asserting its way into someone else's account.
Where we landed: Inert-at-rest storage, an event-driven revocation pipeline, enforced PKCE, domain-bound SSO, and server-side bot protection on the start paths. The core proves sessions. We made the failure modes around it boring on purpose.
Integration, or how our own apps consume identity
A substrate nobody can integrate against is a science project. The last big piece was the seam between re-auth and the products using it, and here we'd already lived through the wrong answer: every app with its own token handling, its own login screen, its own calls into the auth service.
We centralized, in two pieces. A first-party SDK gives every app one vetted way to talk to re-auth: login, token exchange, refresh, verification. A fix lands once and everybody inherits it. And a shared login UI, a single headless form, hardened once, themed per app through nothing but CSS variables, so five products render the same card and differ only in color and logo.
We made one opinionated call on latency. The login round-trip used to be three hops between an app's backend and re-auth. We collapsed it to one, because on the login path latency is a feature and every hop is a tax.
Fig. 3: login round-trip, before and after. Before, BFF relay: 3 hops. After, one headless call: 1 hop. Amber is an app-to-BFF relay hop, teal is the single headless endpoint. What we chose: collapse the BFF's three round-trips into one call on the substrate. Same result, a third of the network, on the path users feel most.
One surface we deliberately did centralize is switching between workspaces. When a signed-in person belongs to more than one, every app hands off to a single re-auth-hosted workspace picker: the same rows, the same entitlement rules, themed per app, instead of each product reimplementing the selector and drifting. It's the mirror image of the login decision below. The login itself stays inside the product, but the in-session act of changing workspace, which looks and behaves identically everywhere, is the one place a shared hosted surface earned its keep.
And one deliberately opinionated default: apps use their own branded, in-app login instead of redirecting to a central hosted page. Login should feel like part of the product, not a detour to an auth server.
Where we landed: One SDK and one shared, per-app-themed login UI, in-app by default, one call on the hot path. Fix identity once, and every product gets it.
The one still open: login-once across tenants
Not every decision is closed, and a technical post that pretends otherwise isn't worth much. The hardest thing we've taken on is cross-tenant single sign-on: the login-once, workspace-selector experience where you authenticate once and pick from every account you belong to, instead of logging in again per tenant.
This is the person axis colliding with the organization axis, exactly as promised. A portable identity has to reach across accounts. The isolation we built exists to stop anything reaching across accounts. Both of those are correct, which is what makes this interesting rather than merely difficult.
Our first design routed each account's login through a central "re-auth ID" hub over OIDC. Clean on paper. Then we checked it against the real topology and it fell over, for a reason worth explaining. Our accounts are isolated dispatch workers on the same platform. The hub was also on that platform. So the handoff was a worker calling a worker inside the same account boundary, which is exactly what the isolation we were so pleased with is designed to stop. The property that makes our tenancy safe is the property that killed this design.
Diagram: why the first hub design died. Inside the per-account plane (red), the hub is only reachable by a worker-to-worker hop our own isolation forbids. Move the hub outside the plane (teal) and the handoff becomes legal. That's the direction we're building.
So we went back with a sharper constraint: the identity hub has to live outside any single account's boundary for the cross-tenant handoff to work at all. That's what we're building now. We'll write it up properly once it's shipped and proven, rather than selling you a diagram of something that hasn't survived contact with production.
Where we landed, for now: Cross-tenant SSO is in flight, not done. The first hub design died on our own isolation boundary, and the hub is moving out of the per-account plane. A proper write-up follows when it's real.
How we built it, and where that bites
One more thing, because it shaped everything above. We built this as an AI-first team. A multi-tenant substrate, an SDK, a shared UI library, an operator console, and integrations across five product repos is a lot of surface for a small group. It got built at that scale because the work ran through fleets of AI agents: working across isolated git worktrees, running the same protocol teardown in parallel, closing security findings, rolling deploys under human review.
Fig. 4: the shape of the fleet. Five products on one shared SDK, three isolated environments per tenant, three login hops collapsed to one, six repos with pipelines green 6/6. The numbers that matter here aren't request rates. They're alignment: how many products share one SDK, how many pipelines agree, whether source matches production everywhere.
That's the part everyone wants to hear about. Here's the part worth being honest about.
Building on a proven core moves your risk from the cryptography to the seams, and AI-fast parallel work drifts exactly there.
The cryptography is handled. better-auth got it right and we don't touch it. What we can still get wrong is the wiring between things. Is every one of five apps on the SDK version we think it is? Did every tenant get the fix we believe we rolled? Does source match production everywhere? Two examples from a single week of building, both boring, both instructive:
A version bump landed in a package manifest but not in the lockfile. Locally, everything passed. In CI, the frozen-lockfile install failed, and because the deploy was gated on CI, it silently skipped the deploy instead of failing loudly. Source and production quietly drifted apart until we went looking for something else and found it.
A strict test assertion outlived the schema it was asserting against, and red-lit a pipeline over fields that were supposed to be gone after a legitimate change.
Diagram: the first seam bug. The defect isn't the lockfile. It's a pipeline whose failure mode is silence, where a skipped deploy and a successful one look the same on the dashboard.
Neither of those is a cryptography bug. Both are seam bugs, and parallel automation produces them faster than a human would. So what we invest in isn't less automation. It's automation pointed at the seams: lockfiles that have to match manifests, deploys that fail loudly instead of skipping, one source of truth for what version every tenant is running, and a human gate on the one step that touches the outside world.
Where we landed: Move fast with agents, then spend the time you saved on the seams. The risk moved from "is the crypto right" to "does the fleet agree." That's where our rigor, and our CI, lives now.
Common pitfalls
Things we've watched go wrong when teams take this route without the whole pattern:
Building on a correct core but leaving config in environment variables. You've dropped the vendor and kept the vendor's worst property, because every customer-specific change is still a deploy.
Choosing database-per-tenant before building the fleet machinery. Structural isolation is a promise to write migration and provisioning orchestration. Make that promise on purpose, or the first schema change becomes an outage.
Treating short TTLs as revocation. They aren't. They're a shorter window in which a banned user still has access, paid for by every healthy login.
Letting each app write its own integration. Five integrations means every security fix ships five times, and the fifth one is the one you forget.
Gating deploys on CI without checking what a CI failure does to the deploy. Skipped and succeeded look identical on most dashboards.
Assuming your isolation boundary allows the handoff you'll want later. We designed a cross-tenant hub our own tenancy model made illegal. Test new topologies against the boundary before you draw them.
Where the bar actually sits now
The old advice was simple: don't build auth. It was good advice, because getting the protocol right was the hard part and almost nobody did.
The protocol isn't the hard part anymore. Correct open-source cores exist and are maintained by people who do nothing else. Platforms like Cloudflare's have taken the operational weight (replication, scheduling, per-tenant database provisioning) and turned it into API calls. What's left is the part that was always yours: tenancy, policy, branding, delegation, the console your operators live in. That part no vendor was ever going to build for you.
So the question isn't "build or buy" anymore. It's whether you have the four things. Experience with systems this complicated. A problem you've scoped tightly enough to finish. A protocol core you trust and don't touch. A platform that means running it isn't a second job.
If you're missing one, buy. Genuinely. We'd have bought without them.
We had all four, so we built re-auth, and I'd make the same call again. Not because building auth is easy. Because the expensive part of it stopped being ours to do.

