Authentication in Next.js sits at the intersection of server and client rendering, which makes it both powerful and tricky to get right. You have proxy (middleware) that can intercept requests before they hit your routes, server components that can check sessions without shipping auth logic to the browser, and server actions that need protection too. The question is never "can I add auth?" but "where should each check live?"
The short answer: proxy handles coarse-grained redirects (is the user logged in?), server components handle fine-grained authorization (does this user have the right role?), and Auth.js (NextAuth v5) ties it all together with OAuth providers, session management, and callbacks. If you're rolling your own auth, you'll use JWTs with the jose library in proxy and cookies() in server components. Either way, the principle is the same: never trust the client, always verify server-side, and keep your auth checks close to where data is accessed.
Master next.js authentication
Take the Next.js Full-Stack course with hands-on lessons and challenges.
Proxy intercepts requests before they reach your routes. This pattern verifies the JWT signature with jose, passes the decoded user info via headers (so server components don't re-verify), and redirects to login with a return URL. The matcher ensures proxy only runs on protected paths -- not on static assets or public pages.
Auth.js v5 exports four things: handlers (for the route handler), auth (to check sessions), signIn, and signOut. The jwt callback fires first and adds custom fields to the token. The session callback then exposes those fields to your app. The Prisma adapter stores users and accounts in your database, enabling account linking and server-side session revocation.
The auth() function from Auth.js works directly in server components -- no client-side JavaScript needed. This runs entirely on the server, so session data never leaks to the browser bundle. Use conditional rendering to show different UI based on roles. The redirect() function from next/navigation throws internally to halt rendering, so no content is rendered for unauthenticated users.
Server actions are reachable via POST requests, so proxy-level auth alone won't protect them. Always call auth() at the top of every server action. This example checks authentication first, then verifies the user owns the resource (or is an admin) before deleting. This is defense in depth -- even if someone bypasses the UI, the server action enforces the rules.
Auth.js v5 lets you wrap your proxy/middleware function with auth(), which attaches the session to the request object. This avoids manually verifying tokens. The ROUTE_ROLES map centralizes your access rules -- if a user's role isn't in the allowed list for that route, they get redirected to an unauthorized page. Check routes from most specific to least specific so /admin matches before /dashboard.
Client components use useSession() from next-auth/react, which requires a SessionProvider wrapper in your root layout. Always handle the three states: loading, unauthenticated, and authenticated. The signIn() function accepts a provider ID to skip the default sign-in page. The signOut() function accepts a callbackUrl to control where users land after logging out. Prefer server-side auth checks for security -- use client-side only for UI rendering decisions.
Relying on proxy (middleware) as the only auth layer -- assuming that because proxy redirects unauthenticated users, server actions and API routes are automatically protected
Proxy only intercepts matched routes. Server actions are callable via POST, and API routes can be hit directly. Always call `auth()` or verify the session at the top of every server action and route handler. Auth should be checked close to where data is accessed, not just at the routing layer.
Storing JWTs in localStorage instead of HttpOnly cookies, exposing them to XSS attacks
Always store tokens in HttpOnly, Secure, SameSite cookies. HttpOnly prevents JavaScript from reading the cookie (blocking XSS token theft), Secure ensures transmission only over HTTPS, and SameSite='lax' provides basic CSRF protection. Auth.js does this correctly by default.
Using long-lived JWTs (days or weeks) without a refresh token mechanism, making it impossible to revoke access after a password change or security incident
Keep access tokens short-lived (15 minutes) and use refresh tokens (7 days, stored server-side) for silent renewal. When you need to revoke access, delete the refresh token from the database. Alternatively, use Auth.js with a database session strategy where sessions are revocable by design.
Checking `session.user.role` only in the UI (client component) to hide admin buttons, without enforcing the same check server-side in route handlers or server actions
Client-side role checks are a UX convenience, not a security boundary. Anyone can call your API directly or invoke server actions with crafted requests. Always enforce RBAC in server components, route handlers, and server actions. Think of client-side checks as hiding buttons, server-side checks as locking doors.
Next.js authentication works best as a layered system. Proxy (middleware) handles the first gate -- redirecting unauthenticated users before pages render. Auth.js (NextAuth v5) provides the authentication infrastructure: OAuth providers, session management, JWT or database strategies, and callbacks for customization. Server components use `auth()` for session checks without client-side JavaScript. Server actions and route handlers each need their own auth verification because they're independently callable. For RBAC, centralize your role definitions, check permissions at every layer, and remember that client-side checks are cosmetic -- the real enforcement happens on the server.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.