Authentication & Authorization in Next.js

1. Introduction 🚀

Every app that has users eventually needs to answer two questions: who are you? and what are you allowed to do? This tutorial covers both sides — authentication and authorization — in a Next.js context, from raw sessions and JWTs to popular libraries like Auth.js and Clerk.

Information

This guide focuses on patterns within the app router, using Route Handlers, Middleware, and Server Actions together.

2. Authentication vs Authorization 🤔

These two terms are often confused, but they answer fundamentally different questions.

ConceptQuestion it answersExample
AuthenticationWho are you?Logging in with email & password
AuthorizationWhat can you do?Only admins can delete users

Tip

A simple way to remember it: AuthNtication verifies identity, AuthZation verifies permission.

3. Authentication Strategies 🧭

  • Session-based: a server-side session referenced by a cookie.
  • Token-based: a signed, stateless token like a JWT.
  • OAuth / Social login: delegating identity to a trusted third-party provider.
  • Passwordless: magic links or one-time codes sent via email or SMS.

4. Session-Based Authentication đŸ—„ī¸

In this model, the server stores session data (in a database or in-memory store) and gives the client only an opaque session ID via a cookie. Every request looks up that ID to identify the user.

app/api/login/route.ts

export async function POST(request: Request) {
  const { email, password } = await request.json();
  const user = await verifyCredentials(email, password);

  const sessionId = await createSession(user.id);
  const response = Response.json({ success: true });
  response.headers.set('Set-Cookie', `session=${sessionId}; HttpOnly; Path=/; Secure`);
  return response;
}

5. Token-Based Authentication đŸŽĢ

Here, the server issues a self-contained token to the client, which sends it back on every request — often in an Authorization header — removing the need for server-side session storage.

app/api/login/route.ts

export async function POST(request: Request) {
  const { email, password } = await request.json();
  const user = await verifyCredentials(email, password);
  const token = signToken({ userId: user.id });
  return Response.json({ token });
}

6. JWT Authentication 🔑

A JWT is a signed, base64-encoded token containing claims (like userId or role) that can be verified without a database lookup — ideal for the Edge Runtime.

lib/jwt.ts

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function signToken(payload: object) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('2h')
    .sign(secret);
}

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, secret);
  return payload;
}

Warning

Never store sensitive data (passwords, secrets) inside a JWT payload — it's signed, not encrypted, and can be decoded by anyone.

7. OAuth 🔗

OAuth lets a user grant your app limited access to their account on another service (like Google or GitHub) without ever sharing their password with you.

User clicks "Sign in with Google"
Redirect to Google consent screen
Google redirects back with an authorization code
Your server exchanges the code for tokens
Session or JWT is issued to the user

8. Social Login đŸ‘Ĩ

Social login is the user-facing application of OAuth — buttons like "Continue with GitHub" or "Continue with Google" that skip password creation entirely.

Information

Most auth libraries covered later in this tutorial (Auth.js, Clerk, Supabase Auth) provide social login providers out of the box, so you rarely need to implement the OAuth flow manually.

9. Password Authentication 🔒

When storing passwords yourself, never store them in plain text. Always hash them with a slow, purpose-built algorithm like bcrypt or argon2.

lib/auth.ts

import bcrypt from 'bcrypt';

export async function hashPassword(password: string) {
  return bcrypt.hash(password, 12);
}

export async function verifyPassword(password: string, hash: string) {
  return bcrypt.compare(password, hash);
}

10. Magic Links ✨

A magic link is a one-time, expiring link emailed to the user; clicking it logs them in without ever typing a password.

app/api/magic-link/route.ts

export async function POST(request: Request) {
  const { email } = await request.json();
  const token = await createMagicToken(email);
  await sendEmail(email, `Click to log in: https://myapp.com/verify?token=${token}`);
  return Response.json({ sent: true });
}

11. Multi-Factor Authentication đŸ›Ąī¸

MFA requires a second proof of identity — a time-based code, an SMS code, or a hardware key — in addition to a password, significantly reducing account takeover risk.

  1. User enters email and password (first factor).
  2. Server prompts for a one-time code (second factor).
  3. User provides the code from an authenticator app or SMS.
  4. Server verifies both factors before issuing a session.

12. Better Auth 🧩

Better Auth is a modern, framework-agnostic authentication library with first-class Next.js support, offering built-in session management, OAuth providers, and plugins for things like MFA.

lib/auth.ts

import { betterAuth } from 'better-auth';

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  socialProviders: {
    google: { clientId: process.env.GOOGLE_ID!, clientSecret: process.env.GOOGLE_SECRET! },
  },
});

13. Auth.js (NextAuth.js) 🔐

Auth.js, formerly known as NextAuth.js, is one of the most widely used authentication solutions in the Next.js ecosystem, supporting dozens of OAuth providers alongside credentials-based login.

auth.ts

import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
});

app/api/auth/[...nextauth]/route.ts

import { handlers } from '@/auth';
export const { GET, POST } = handlers;

14. Clerk 🎨

Clerk is a hosted, drop-in authentication provider that handles login UI, session management, and user management dashboards, so you can add auth with minimal custom code.

middleware.ts

import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();

15. Supabase Auth đŸŸĸ

Supabase Auth pairs a SQL database with built-in authentication, letting you reuse your existing Supabase project for user management and social login providers.

lib/supabase.ts

import { createServerClient } from '@supabase/ssr';

export function createClient() {
  return createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    { cookies: { /* cookie adapter */ } }
  );
}

16. Firebase Authentication đŸ”Ĩ

Firebase Authentication offers similar hosted auth capabilities backed by Google's infrastructure, commonly paired with Firestore for storing additional user data.

lib/firebase.ts

import { initializeApp } from 'firebase/app';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';

const app = initializeApp({ apiKey: process.env.FIREBASE_API_KEY });
export const auth = getAuth(app);

17. Middleware Authentication 🚧

Regardless of which library you use, Middleware is the natural checkpoint for verifying a session or token before a request ever reaches a page.

middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/jwt';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;
  const valid = token ? await verifyToken(token).catch(() => null) : null;

  if (!valid) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*'] };

18. Protected Routes 🔒

Beyond middleware, individual Server Components can also check the session directly, giving you a second layer of defense and access to user data for rendering.

app/dashboard/page.tsx

import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';

export default async function DashboardPage() {
  const session = await getSession();
  if (!session) redirect('/login');

  return <div>Welcome, {session.user.name}!</div>;
}

19. Role-Based Access Control 👑

RBAC assigns each user a role (e.g. admin, editor, viewer) and restricts access based on that single label.

app/admin/page.tsx

import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';

export default async function AdminPage() {
  const session = await getSession();
  if (session?.user.role !== 'admin') redirect('/unauthorized');

  return <div>Admin Dashboard</div>;
}

20. Permission-Based Access Control 🧱

Where roles get too coarse, permission-based access control assigns granular capabilities (e.g. posts:delete, users:invite) directly to a user or role, offering finer control.

lib/permissions.ts

export function hasPermission(user: User, permission: string) {
  return user.permissions.includes(permission);
}

// Usage
if (!hasPermission(session.user, 'posts:delete')) {
  return Response.json({ error: 'Forbidden' }, { status: 403 });
}

21. Session Management đŸ—‚ī¸

Good session management means tracking issued, active, and expired sessions, and giving users visibility into (and control over) their own active sessions.

  • Set a reasonable expiration time and rotate session identifiers periodically.
  • Allow users to view and revoke active sessions from other devices.
  • Invalidate all sessions immediately on password change.

22. Cookies đŸĒ

Session tokens and JWTs should be stored in HttpOnly, Secure cookies — never in localStorage, which is readable by any script and vulnerable to XSS.

app/api/login/route.ts

response.headers.set(
  'Set-Cookie',
  `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`
);

23. Refresh Tokens 🔄

A refresh token is a long-lived, securely stored token used to obtain a new, short-lived access token once the current one expires — without forcing the user to log in again.

app/api/refresh/route.ts

export async function POST(request: Request) {
  const refreshToken = request.cookies.get('refresh_token')?.value;
  const stored = await validateRefreshToken(refreshToken);

  if (!stored) {
    return Response.json({ error: 'Invalid refresh token' }, { status: 401 });
  }
  const newAccessToken = await signToken({ userId: stored.userId });
  return Response.json({ accessToken: newAccessToken });
}

Best Practice

Keep access tokens short-lived (minutes) and refresh tokens longer-lived but rotatable — reissue a new refresh token on every use and invalidate the old one.

24. Logout đŸšĒ

Logging out means clearing the session cookie and invalidating the corresponding session or token on the server, so a stolen cookie can't be replayed after logout.

app/api/logout/route.ts

export async function POST(request: Request) {
  const sessionId = request.cookies.get('session')?.value;
  if (sessionId) await destroySession(sessionId);

  const response = Response.json({ success: true });
  response.headers.set('Set-Cookie', 'session=; Path=/; Max-Age=0');
  return response;
}

25. User Profiles 👤

Once authenticated, most apps store additional profile data (name, avatar, preferences) linked to the user's identity, usually in your own database, keyed by the ID from your auth provider.

app/profile/page.tsx

export default async function ProfilePage() {
  const session = await getSession();
  const profile = await db.profile.findUnique({ where: { userId: session.user.id } });
  return <ProfileForm profile={profile} />;
}

26. Security Best Practices 🔒

  • Always hash passwords with bcrypt or argon2 — never store them in plain text.
  • Use HttpOnly, Secure, SameSite cookies for session and token storage.
  • Rotate refresh tokens on every use to detect token theft.
  • Rate-limit login and password-reset endpoints to prevent brute-force attacks.
  • Enforce MFA for sensitive accounts wherever possible.

Danger

Never roll your own cryptography for password hashing or token signing — rely on well-audited libraries like bcrypt, argon2, and jose.

27. Performance Considerations đŸŽī¸

  • Prefer stateless JWT verification in Middleware (Edge Runtime) over full database session lookups on every request.
  • Cache non-sensitive user profile data with React's cache() to avoid duplicate database calls per render.
  • Keep JWT payloads small — large tokens add latency to every request that carries them.

28. Common Authentication Errors đŸšĢ

Common Authentication Mistakes
Storing tokens in localStorage instead of HttpOnly cookies
Checking authorization only in the UI, never on the server
Token handling mistakes
Forgetting to invalidate sessions server-side on logout
Never rotating or expiring refresh tokens
Putting sensitive data inside an unencrypted JWT payload

29. Frequently Asked Questions ❓

For most production apps, an established library (Auth.js, Clerk, Supabase Auth) is safer and faster to ship than a custom implementation — reserve fully custom auth for very specific requirements.

Middleware is a strong first line of defense, but sensitive Server Actions and Route Handlers should still independently verify the session — never rely on a single checkpoint.

In an environment variable, never committed to source control, and never exposed to the client — only accessed from server-side code.

30. Summary 📚