Authentication is a hurdle every application has to tackle. Whether you choose a polished SaaS provider, an open source project, or roll your own solution, the decision isn’t easy and the right pick can make or break your contributor experience and future scalability.
I want to take you through our journey with Unkey: how we started with Clerk, why that was great (and where it fell short), and exactly how we transitioned to our own custom authentication abstraction layer that powers both smooth self-hosting and amazing developer experience.
Why Authentication Choice Matters
Every app no matter if it’s a tiny hobby project or a massive SaaS platform needs authentication. The options are endless and boil down to three routes:
Software as a Service (SaaS) providers (like Clerk, Auth0, etc.)
Open Source Projects (Lucia, Auth.js, etc.)
Roll-your-own homebrewed auth system
Each of these comes with tradeoffs. SaaS is quick and enterprise-ready, but sometimes comes with lock-in or complicated local dev. Open source gives you control and self-hosting, but means more hands-on maintenance. And building your own... Well, that’s opening a whole can of worms.
In my experience, for 99.99% of projects, picking a SaaS like Clerk is the fastest way to get started, but when you're building an open source project where contributing should be easy, things get tricky fast.
Our Experience Starting with Clerk
When we kicked off Unkey, Clerk made getting started super easy. We had:
an authentication layer up in minutes
analytical data at our fingertips
organizations and team management (since our core model is “workspaces”)
all those nice-to-have enterprise auth features
It was honestly great when you’re shipping fast and want to focus on actual product features.
Where Clerk Fell Short for Open Source Contributors
Open source projects live and die by how easy it is for others to contribute. Using Clerk behind the scenes meant every contributor had to:
Sign up for their own Clerk instance
Enable organizations
Configure Google, GitHub, and Magic code
Add Clerk API keys and secrets
No matter how much we documented it, spinning up a local dev environment became way too much friction. Contributors working on even the tiniest bug had to spend time registering with Clerk, setting up OAuth providers, and configuring secrets.
This was not sustainable.
There were way too many DM’s and issue comments like:
"I just want to fix a typo, why do I have to register for Clerk first?"
Instead of focusing on features, we were writing docs and answering setup questions. That’s not the vibe for an open source project.
Figuring Out What We Actually Needed
It was time to go back to the drawing board. Around April 2024, we had a big offsite and listed out EXACTLY what we needed for Unkey’s auth system for both maintainers and contributors.
Core Requirements
For Production
Well-supported SaaS provider (for scaling with security and compliance)
Enterprise features: SSO, SAML, organization management, etc.
Organizations as a first-class citizen (since Unkey’s “workspaces” are fundamental)
Scalability as we grow (not getting bottlenecked by our own auth)
For Contributors & Local Dev
Zero setup for developers (should be able to clone, run, and go)
In-memory local auth: one user, one workspace, always “signed in”
Works offline and is self-contained no third-party outages breaking dev
Consistent behavior between local and production flows
For Us (the Maintainers)
Provider swap at any time: no heavy code rewrites if we want to switch SaaS or go open source
Clear separation of auth logic from business logic
Easy testing
Minimal lock-in to any provider
Simple self-hosting for folks who want their own instance
And possibly most important:
“We wanted contributing to feel like ‘clone, run, and go’... not ‘sign up for 3 SaaS products and mess with secrets for an hour’.”
Designing the Auth Abstraction Layer
To solve all this, we needed an auth abstraction layer that could:
Swap backend providers (SaaS or open source) with minimal fuss
Seamlessly switch between a “real” production auth system and a dummy in-memory fake for local/dev/test
Keep a clean boundary between “auth stuff” and “business logic”
This meant some big architectural changes.
We had to:
Rip apart the old, intertwined code (where business logic and auth were bound together)
Design interfaces and “providers” for plug-and-play swapping
Build specialized tools for local auth something that required no external dependencies, worked offline, and just “magically” signed users in
A Deep Dive: How the Auth Layer Works
The Big Picture
At a high level, our new system is:
The app makes all auth requests through our abstraction layer
The layer decides, based on environment, whether to talk to a real provider (like WorkOS) or run an in-memory fake
All business logic downstream just “trusts” the auth layer and gets consistent info
Typical Flow (With Real Provider):
User submits email to sign in
Our base auth abstraction passes this to, say, WorkOS
WorkOS handles the magic link/code flow
User submits the code
We verify the code via WorkOS
If successful, we set a cookie/session
Local Flow (Dev In-Memory Auth):
Dev launches the dashboard
System auto-signs them in as a stub user (no auth UI, no code input)
A dummy cookie gets set that’s always “valid”
From here, all auth checks short-circuit to “yes, user is signed in”
"In local mode, this is automatically done for the user. As soon as they launch the dashboard, it just signs them in... They never see the sign in page."
Organization Magic
Because Unkey is all about teams/workspaces, the abstraction layer also ensures:
Every “user” (even in local mode) is in an org/workspace
Org/team APIs always work as expected, so developers can test the full flow without additional setup
Technical Challenges (And How We Crashed Into Them)
Of course, this wasn’t just flipping a few switches. Here are the main technical battles—and what we ended up building to fight them.
1. Next.js Cookie Chaos
If you’ve ever tried working with cookies in Next.js, you know it’s... let’s call it “quirky”.
Server components vs client components vs API routes all have their own quirks and timing issues
Ensuring cookies were set, read, and updated correctly everywhere was way trickier than anticipated
We ended up writing custom utilities to set, get, and validate cookies no matter where the code was running
“Ensuring consistent cookie access and management required really careful planning… [and] a lot of debugging.”
2. Session Handling and Caching
We needed fast user lookup but not too fast. If we cached session info too aggressively, users got stuck with out-of-date data. If we didn’t cache enough, we hammered our backends with unnecessary reads.
Several iterations later, we ditched some built-in Next.js utilities and switched to tools like tRPC to get better control over session caching and invalidation.
Lessons Learned:
Always invalidate session caches after changing user/org data
Don’t trust “clever” Next.js helpers too much; sometimes hand-rolled is safer
No more duplicate calls. Happy backend.
4. Middleware and Local/Provider Parity
Our middleware ensures that only authenticated sessions can load protected routes (like the dashboard). We needed it to work the same way, whether the app was running locally (in fake auth mode) or in production (with a real SaaS provider).
Had to handle token refreshes, expiration, and all the edge cases
Error-handling and redirection logic needed to be 100% parallel in local and live mode
It took a lot of tweaking to make middleware context and cookies “just work” across these different setups.
5. Decoupling Business Logic from Auth
Originally, a lot of core features were entangled with the specifics of the Clerk API. Team invites, workspace management, user settings everything called right into Clerk.
We spent weeks refactoring:
Pulling all references to auth provider APIs into the abstraction layer
Writing new interfaces so business logic ran on generic user/session/org data, not provider specifics
Moving all “who is logged in?” questions to ask our abstraction, not Clerk/WorkOS directly
Now, we can swap in any provider (or none at all!) by swapping code behind the interface not touching feature code.
6. Real-World Bugs We Shipped (and Fixed)
Let’s be honest we shipped some bugs along the way.
Token Refresh (or, “Why Am I Getting Logged Out Every 5 Minutes?”)
Most OAuth providers give you a short-lived access token and a longer “refresh token.” If you don’t handle token refreshing, users get logged out as soon as the access token expires.
We only discovered this after shipping to production, when users started complaining:
“I just got kicked out of the dashboard after 5 minutes... Now I’m stuck on a loading spinner.”
Both Google and GitHub OAuth required us to implement full token refresh cycles, and to ensure the session was seamlessly refreshed in the background.
Once we fixed that (and combined it with auth request deduplication), sessions became smooth and reliable again.
What We Learned and Where We’re Going
Transitioning away from Clerk (and away from any direct provider lock-in) was a massive refactor but it was worth every hour:
Contributor friction is now basically zero you can just clone, run, and hack
Self-hosters can choose any provider, or just use local fake auth
We can swap production providers any time, with no wild rewrites
Code is clean(er), with obvious boundaries between business and auth logic
A Last Word to Open Source Maintainers
“No matter how we documented it, there was no easy way to direct people through the Clerk setup. So we decided to fix this problem at the core.”
If you’re running an open source project, prioritize easy contribution. Authentication shouldn’t stand in the way of new maintainers, bug fixers, or your own ability to keep shipping.