Production Authentication Architecture & Session Security
Authentication establishes who a user is, while authorization determines what resources they can access. Storing authentication tokens in browser localStorage or omitting server-side middleware invites catastrophic XSS and CSRF token theft. This guide details resilient authentication implementation using NextAuth.js (Auth.js) and Firebase Authentication.
1. Session Storage Architecture: Cookie Security vs LocalStorage
The single most critical security decision in modern web authentication is where tokens reside:
- Vulnerable: LocalStorage / SessionStorage: Any script running in the browser (including third-party analytics, chat widgets, or injected XSS payloads) can read
localStorage.getItem('token')and transmit it to an attacker's server. Never store JWTs or session secrets in browser storage. - Secure: HTTP-only Cookies: When a cookie has the
HttpOnlyflag, browser JavaScript cannot access it viadocument.cookie.HttpOnly: true- Completely prevents client script token extraction.Secure: true- Guarantees cookies are transmitted exclusively over encrypted HTTPS connections.SameSite: 'Lax' or 'Strict'- Prevents Cross-Site Request Forgery (CSRF) attacks by restricting cookie transmission on cross-origin requests.
2. NextAuth.js / Auth.js (v5) Implementation
NextAuth.js provides standardized session management with built-in CSRF defenses and OAuth state verification.
// auth.config.ts
import type { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
export const authConfig: NextAuthConfig = {
providers: [
Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET }),
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
Credentials({
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await db.user.findUnique({ where: { email: String(credentials.email) } });
if (!user || !user.passwordHash) return null;
const isValid = await bcrypt.compare(String(credentials.password), user.passwordHash);
if (!isValid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
}
})
],
session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60 }, // 30 days
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as any).role || 'user';
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as any).role = token.role;
}
return session;
}
}
};
3. Edge Middleware Route Protection
Do not rely solely on client-side redirect hooks. Edge middleware executes before incoming requests reach server components or API route handlers, eliminating flash of unauthorized content.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(req: NextRequest) {
const token = await getToken({ req, secret: process.env.AUTH_SECRET });
const { pathname } = req.nextUrl;
// Protect admin routes
if (pathname.startsWith('/admin')) {
if (!token) {
return NextResponse.redirect(new URL('/login?callbackUrl=' + pathname, req.url));
}
if (token.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', req.url));
}
}
// Protect authenticated user dashboard
if (pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login?callbackUrl=' + pathname, req.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};
4. Firebase Authentication Server Token Verification
When using Firebase Authentication on the client, verify ID tokens on your backend before granting access to sensitive data:
// app/api/v1/user/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import * as admin from 'firebase-admin';
export async function GET(req: NextRequest) {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const userId = decodedToken.uid;
// Process privileged request for authenticated userId
return NextResponse.json({ success: true, userId, email: decodedToken.email });
} catch (error) {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 });
}
}