How I Replaced Next-Auth with a 5-Provider OAuth Proxy
Auth is one of those things that seems solved until you're managing it across eight different apps, each with its own OAuth credentials, callback URLs, and session strategy. When the library you depend on ships breaking changes to all of them at once, you start wondering if there's a better way.
I've configured Next-Auth (now Auth.js) more times than I can count. Each time it's a slightly different dance: install the package, set up providers, configure callbacks, wire up the database adapter, debug the session strategy, and then pray that the next major version doesn't change the API again.
When Next-Auth v5 shipped with breaking changes to the session handling I'd just finished implementing across three projects, I decided I was done adapting to someone else's abstraction.
The Problem with Per-App Auth
When every app owns its own auth:
- Each project needs its own Google Cloud Console OAuth credentials
- Each project configures its own callback URLs, session strategy, and token handling
- A breaking change in the auth library means updating every project
- Testing auth flows means running each project's auth stack independently
- You're one misconfigured
NEXTAUTH_SECRETaway from sessions that silently fail
I had eight projects. Eight auth configurations. Eight sets of OAuth credentials. Eight opportunities to mess something up.
The Architecture
I built a single auth service that runs as a FastAPI microservice alongside each project. But the OAuth flow is centralized through auth.hostkit.dev:
Browser → app.hostkit.dev/login
→ Redirect to auth.hostkit.dev/oauth/google
→ Google consent screen
→ Callback to auth.hostkit.dev/callback
→ JWT minted with RS256
→ Redirect back to app.hostkit.dev/api/auth/callback/token
→ iron-session cookie set
The key insight: the OAuth proxy handles the provider dance once. Individual apps never talk to Google or Apple directly. They receive a signed JWT and validate it with a public key.
Five Providers, One Service
The auth service supports five authentication methods:
Email/Password — Bcrypt hashing, automatic email verification. The most straightforward option for admin accounts.
Magic Link — 15-minute expiry, one-time use tokens. I use this as the default for client portals. No passwords to forget, no reset flows to build. The client clicks a link in their email and they're in.
Google OAuth — Handled entirely by the proxy. Apps don't need Google Cloud Console access. The redirect URI is always auth.hostkit.dev, not the individual app domain.
Apple Sign-In — ES256 token validation. Apple's auth flow is notoriously finicky — I debugged it once and never want to do it again. The proxy handles all the JWT assertion nonsense.
Anonymous — Creates a session without credentials. Useful for letting users interact with an app before committing to an account. The anonymous session can be upgraded to a full account later.
JWT + Public Key Validation
Here's where the performance win comes in. The auth service signs tokens with RS256 (an asymmetric algorithm), and each app only needs the public key to verify them. That means token validation happens locally in the app's middleware — no network call back to the auth service, no database lookup.
// No API call needed — just validate the signature
const user = await verifyJWT(token, process.env.AUTH_JWT_PUBLIC_KEY)
This is the performance win I didn't expect. With Next-Auth, every protected page was either calling /api/auth/session or hitting the database to validate a session. With RS256, validation is a pure cryptographic operation. No network call, no database query.
Session Management
I use iron-session for the cookie layer. The JWT from the auth service gets stored in an encrypted, httpOnly cookie with a 30-day expiry. The session contains the user's ID, email, name, and role — enough to render the UI without a database lookup.
export async function getCurrentUser(): Promise<SessionUser | null> {
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions
)
return session.user ?? null
}
RBAC is straightforward: the JWT includes a role field (client or admin). Middleware checks the role before allowing access to admin routes. No complex permission tables, no role hierarchies. Two roles cover every use case I've encountered.
What I Don't Miss
- Version upgrade treadmill. The auth service is mine. It changes when I change it.
- Provider credential sprawl. One set of Google OAuth credentials. One Apple developer config.
- Callback URL whack-a-mole. Every app's callback goes through the same proxy. Adding a new app doesn't require touching Google's console.
- Session debugging.
iron-sessionis a thin cookie wrapper. When something's wrong, there's no five-layer abstraction to dig through.
The Tradeoff
I'm maintaining an auth service now. That's real work. Security patches, token rotation, provider API changes — they're my problem. But they were always my problem; I was just outsourcing the first layer of abstraction to a library that moved faster than I wanted it to.
For a solo developer building one app, use Auth.js or Clerk. They're great. But if you're running a platform with multiple apps that share an auth boundary, a centralized service pays for itself quickly.
