ahmedallem.
Engineering · 7 min read

Next.js Authentication Patterns I Use Across 9 Products

Next.js auth patterns that survived real users, security scans, and 3 AM incidents - JWT, HTTP-only cookies, and refresh token rotation.

Ahmed Allem

Ahmed Allem

Founder & CTO · Aviation, AI & Startups

ShareShare
Next.js Authentication Patterns I Use Across 9 Products

I've built authentication systems for more Next.js applications than I care to count. Aviation Infinity, ClickAi, Babonbo, HackIfy -- each one has users, each one needs auth, and each one taught me something about what works and what does not.

After years of building auth, I've converged on a set of patterns that I use across all my products. These aren't theoretical best practices. They are patterns that have survived real users doing unexpected things, security scans revealing embarrassing vulnerabilities, and 3 AM production incidents.

The Foundation: JWT + HTTP-Only Cookies

I've tried session-based auth, token-based auth, and various hybrid approaches. I've settled on JWTs stored in HTTP-only cookies as my default approach.

Here is why:

HTTP-only cookies can't be accessed by JavaScript running in the browser. This eliminates an entire class of XSS-based token theft attacks. Local storage is convenient but it's trivially accessible to any JavaScript running on your page, including third-party scripts and injected code.

JWTs are self-contained. The server can validate a token without hitting a database. This matters when you have API routes that receive hundreds of requests per second -- eliminating a database lookup per request makes a real performance difference.

Cookies are sent automatically with every request to the same domain. No need to manually attach headers in your fetch calls. This simplifies the client-side code significantly.

The tradeoff is that JWTs can't be revoked before they expire unless you maintain a blacklist, which reintroduces the database lookup you were trying to avoid. I handle this with short-lived access tokens (15 minutes) and longer-lived refresh tokens (7 days). If I need to revoke access immediately, I invalidate the refresh token in the database, and the access token expires within 15 minutes.

Middleware for Route Protection

Next.js middleware runs before every request. This is the natural place to check authentication for protected routes.

My middleware pattern is straightforward. It reads the JWT from the cookie, validates it, and either allows the request to proceed or redirects to the login page. For API routes, it returns a 401 instead of redirecting.

The key insight is to keep middleware fast. Middleware runs on every request, including requests for static assets. If your middleware is slow, your entire application is slow. This means no database calls in middleware. JWT validation is purely cryptographic -- verify the signature, check the expiration, done.

I use a route configuration object that defines which paths are public, which require authentication, and which require specific roles. This keeps the middleware logic clean and makes it easy to see the access control policy at a glance.

The Auth Context Pattern

On the client side, I use a React context that provides the current user's authentication state to the entire application. The context exposes the user object, loading state, login function, logout function, and a refresh function.

The context initializes by calling a /api/auth/me endpoint that returns the current user based on the JWT cookie. This endpoint is called once on app load and after any authentication action (login, logout, token refresh).

I've seen patterns where the JWT is decoded on the client to extract user information. I avoid this. The client shouldn't be parsing tokens. The server should provide user information through a dedicated endpoint. This keeps the contract clean and means I can change the token format without updating any client code.

Patterns That Work Across Products

For consumer products like Babonbo, I use magic links (passwordless email authentication) as the primary login method. Users enter their email, receive a link, click it, and they're logged in.

Magic links eliminate an entire category of problems: password reset flows, password strength requirements, credential stuffing attacks, and the cognitive load of yet another password. The user experience is simpler and the security is arguably better because there's no password to steal.

The implementation is straightforward. Generate a single-use token, store it in the database with an expiration (I use 10 minutes), send it via email, and exchange it for a session when the user clicks the link.

For products with enterprise or professional users (like Aviation Infinity where flight schools have admin accounts), I add traditional email/password as an option. Professional users expect it and may need to share account access in ways that magic links don't support well.

OAuth for Speed

Every product supports "Sign in with Google" at minimum. Some add Apple and GitHub depending on the audience. OAuth dramatically reduces signup friction. Users click one button, authorize, and they're in.

The implementation across my products is standardized. I use the same OAuth callback handling code, the same token exchange logic, and the same user creation/linking flow. When a user signs in with OAuth, I check if an account with that email already exists. If it does, I link the OAuth provider to the existing account rather than creating a duplicate. This handles the common case where a user signs up with email and later tries to sign in with Google.

Role-Based Access Control

Most of my products need at least two roles: regular user and admin. Some need more. Aviation Infinity has students, instructors, and school administrators. Babonbo has renters, providers, and platform admins.

I store roles as an array on the user document in MongoDB. The JWT includes the user's roles. Middleware checks roles for protected routes. API routes check roles before performing actions.

The pattern I've found most useful is to check permissions, not roles, in application code. Instead of if (user.role === 'admin'), I use if (user.can('manage_users')). The mapping from roles to permissions is defined in one place. This makes it easy to add new roles or modify existing ones without touching application logic throughout the codebase.

Rate Limiting on Auth Endpoints

Auth endpoints are the most attacked endpoints in any web application. Login, registration, password reset, and magic link endpoints all need rate limiting.

I use a simple sliding window rate limiter backed by an in-memory store (for single-instance deployments) or Redis (for multi-instance deployments). The limits vary by endpoint: login attempts are limited to 5 per minute per IP, magic link requests to 3 per minute per email, and registration to 10 per hour per IP.

The rate limiter returns a 429 status with a Retry-After header. The client shows a user-friendly message explaining the limit. This is important -- users who hit rate limits are often legitimate users who mistyped their password, not attackers. The error message shouldn't make them feel like criminals.

The Mistakes

Mistake 1: Rolling my own everything. In my early products, I built auth from scratch because I wanted to understand every piece. I learned a lot, but I also shipped vulnerabilities. These days, I use established libraries for the cryptographic primitives and focus my custom code on the application-specific logic.

Mistake 2: Ignoring session management. Early versions of my products didn't have a way for users to see active sessions or revoke access from other devices. Users expect this now, and it's a legitimate security feature. Every product should have a "sessions" page where users can see and revoke active sessions.

Mistake 3: Not logging auth events. I used to log only errors. Now I log every auth event: login, logout, token refresh, failed login attempt, role change. This audit log has been invaluable for debugging user-reported issues and for security reviews.

The Reusable Auth Module

After implementing auth so many times, I've extracted a reusable module that I drop into every new Next.js project. It handles JWT creation and validation, cookie management, middleware, the auth context, the /api/auth/* endpoints, and the login/signup UI components.

Starting a new project with auth that works out of the box saves me days of development time and ensures I don't forget critical security measures. The module is opinionated -- it makes the decisions I described above -- but it's configurable enough to handle the differences between my products.

If you're building multiple Next.js products, I strongly recommend extracting your auth into a reusable package. The upfront investment pays for itself on the second project.

Next.js Authentication Patterns I Use Across 9 Products | Ahmed Allem