DEV Community

Gaberial Sofie
Gaberial Sofie

Posted on

Deleting Hand-Rolled Auth From a Next.js App: A Keycloak Nextjs Threat Model and NextAuth Cutover

The exposure

Every codebase has one module nobody wants their name on. Ours was lib/auth.ts — a homegrown JWT system in our Next.js app that had been "good enough for the MVP" three years earlier. It signed its own tokens, hand-rolled refresh-token rotation, and stored sessions in a way two engineers actively disagreed about. Exactly one person understood the rotation logic, and when he took vacation we froze all auth-adjacent work out of fear.

That is a security exposure before it is an engineering one. Hand-rolled authentication concentrates several of the highest-consequence failure modes in software into code that is rarely reviewed and understood by one person. A keycloak nextjs integration was attractive not because Keycloak is fashionable but because delegating authentication moves password storage, MFA, token signing, and refresh — the parts where a subtle bug is a breach, not a bug — out of our codebase entirely. The trigger was concrete: a security review flagged four separate issues in that one file. Rather than patch a system we did not trust, we ripped it out and stood on an identity provider we already ran, leaning on the official Auth.js Keycloak provider and Next.js App Router route handlers. The result was about forty lines.

Threat model

Being specific about what a hand-rolled auth module gets you exposed to is what justified deleting it rather than patching it.

  • Self-implemented token cryptography. A module that signs and verifies its own JWTs is one weak-algorithm choice or one missing signature check away from token forgery. This is the class of defect where "it works" and "it is secure" look identical from the outside.
  • Refresh-token rotation errors. Refresh logic understood by a single engineer is fragile in the worst place: a bug can either lock users out (availability) or fail to invalidate a stolen refresh token (a persistent-access foothold). The four review findings clustered here.
  • Ambiguous session handling. Two engineers disagreeing on how sessions are stored is a session-fixation and session-invalidation risk waiting to surface — you cannot invalidate a session cleanly if you cannot agree on where it lives.
  • Knowledge concentration as a security risk. Auth that only one person understands cannot be safely reviewed, patched, or incident-handled. Bus factor one on the credential path is itself a finding.

The decision: reduce our attack surface by owning as little authentication code as possible, and let Keycloak — whose entire job is to get password storage, MFA, and token signing right — own the security-critical parts.

Controls we added

Control 1 — delegate authentication, own almost none of it

NextAuth.js (branded Auth.js in v5; the next-auth package and Keycloak provider are the same) ships a first-party Keycloak provider. You declare a provider, point it at your realm's issuer URL, and it handles the Authorization Code flow, callbacks, and session cookies. The security-relevant property is subtraction: the token exchange, password handling, and MFA all move behind Keycloak, so the surface where our own code can be wrong shrinks to configuration. I cross-referenced the official provider docs against a thorough third-party walkthrough of the NextAuth-Keycloak wiring → while doing this.

One trap that had burned us: do not mix Pages Router examples into an App Router app. Most stale tutorials use NextApiRequest and pages/api/auth; in App Router you export GET/POST from a route.ts, per the Next.js file convention. Getting that straight up front is a correctness control — a subtly wrong handler on the auth path is not a place to be copy-pasting.

The whole integration lives at app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth'
import KeycloakProvider from 'next-auth/providers/keycloak'

const handler = NextAuth({
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID!,
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
      issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  session: { strategy: 'jwt' },
})

export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode

The issuer must include the realm (/realms/<name>) — the provider docs are blunt about it, and a wrong issuer path is a common and security-relevant misconfiguration because it changes which authority you are actually trusting.

Control 2 — client configuration as an enforced allow-list

In Keycloak we created a nextjs-client (confidential, with a client secret) and treated two fields as controls, not preferences:

  • Valid Redirect URIs: http://localhost:3000/api/auth/callback/*, matched character for character including protocol and trailing slash. A loose or wildcard redirect is an open-redirect and token-exfiltration vector, so it is scoped to exactly where NextAuth listens.
  • Access type: confidential for the server-side surface, holding a secret; public clients get PKCE instead. Getting this classification wrong is a genuine weakness, not a style choice.

Control 3 — hardening we adopted deliberately

  • PKCE for public clients, always — RFC 7636 exists precisely to stop authorization-code interception on clients that cannot hold a secret; modern Auth.js enables it for OIDC providers by default.
  • KEYCLOAK_CLIENT_SECRET and NEXTAUTH_SECRET generated per environment (openssl rand -base64 32), pushed through the secrets pipeline, never committed, and rotated on a cadence.
  • HTTPS end-to-end with secure cookies in production.
  • Explicit session strategy (jwt, chosen on purpose) rather than silently mixing jwt and database sessions, which is a classic footgun with real invalidation consequences.

Control 4 — a cutover that never locked anyone out

Swapping authentication on a live product is a high-risk change, so we staged it to stay defensible throughout. We shipped the Keycloak route behind a feature flag and ran it in parallel with the old system for a week, dogfooding internally. We migrated user identities into Keycloak ahead of time and mapped them by email so nobody had to re-register — a re-registration flow is itself a phishing pretext we did not want to create. We kept the old lib/auth.ts in the tree but unreferenced for one release as a rollback escape hatch, then deleted it once the dashboards stayed green — a dormant second auth system is standing attack surface, so it did not linger. The cutover was a single flag toggle at low traffic, and the rollback plan was toggling it back.

Verifying the control, not just shipping it

The failure that validated the approach was a redirect loop that appeared the instant we deployed to staging: no useful error. The cause was a mismatched redirect URI down to a trailing slash the Keycloak Valid Redirect URI did not have. That is worth internalizing as a security habit, not just a debugging tip — redirect-URI matching is the control that keeps authorization codes from going to the wrong place, so when it is strict enough to break on a trailing slash it is also strict enough to reject an attacker's callback. We aligned it and added the check to our deploy runbook. Because authentication now lives behind Keycloak, "does login work" is answerable by driving the flow against the provider rather than reading our own crypto.

Residual risk / what we're still watching

Deleting the hand-rolled module removed a class of self-inflicted crypto and rotation defects, but it relocated risk into configuration and into a dependency.

  • Access-token refresh is the next sensitive control. Keycloak's default access-token lifespan is five minutes, so refresh matters sooner than teams expect. Refreshing OAuth tokens inside the jwt/session callbacks — the Auth.js Refresh Token Rotation guide is the canonical approach — must handle a failed refresh by forcing re-authentication rather than silently serving a stale identity. This is the piece we are implementing most carefully.
  • Back-channel logout is a real gap until we close it. Today, signing out of Keycloak does not yet invalidate the Next.js session everywhere. Until back-channel logout is wired, a revoked or logged-out user can retain a valid app session until it expires — a bounded but genuine residual window we are actively tracking.
  • Redirect-URI and secret drift. A later loosening of the redirect allow-list, or a leaked/committed NEXTAUTH_SECRET or client secret, quietly reintroduces exposure. Both live in reviewed configuration and rotation.
  • Keycloak is now a dependency on the login path. We traded code we owned and distrusted for a system we must keep patched, monitored, and available. That is the right trade, but it makes Keycloak's own security posture part of ours, and we track its advisories.
  • Role-based access mapping. As we map Keycloak realm roles into the jwt/session callbacks for authorization, each mapping is a new place a claim can be misread into wrong access, so those callbacks get the same scrutiny the token logic does.

The net effect is that the scariest authentication code we owned is gone, the parts that are hard to get right now live in a system built to get them right, and the remaining risks are named, bounded, and on a list — chiefly refresh handling and back-channel logout — rather than concentrated in one engineer's head.

Sources & further reading

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Deleting hand-rolled auth is often the best security feature a team can ship. The important part is the threat model around the cutover: sessions, redirects, account linking, and what happens to old assumptions after the new boundary exists.