Auth Security Skills
Implement secure authentication and authorization for cyber-hardened Next.js 16 applications with Auth.js v5.
Table of Contents
- •Secure Auth.js Setup
- •Password Security
- •Session Management
- •Access Control
- •Multi-Factor Authentication
- •Brute Force Protection
- •OAuth Security
- •JWT Security
- •Best Practices
Secure Auth.js Setup
Basic Configuration
typescript
// auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import { verifyPassword } from '@/lib/security/password';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(1).max(128),
});
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Validate input
const result = loginSchema.safeParse(credentials);
if (!result.success) {
return null;
}
const { email, password } = result.data;
// Find user
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: {
id: true,
email: true,
name: true,
password: true,
role: true,
lockedUntil: true,
failedAttempts: true,
},
});
if (!user || !user.password) {
// Prevent timing attacks - always verify even with no user
await verifyPassword('dummy', '$2b$12$dummy.hash.for.timing.attack.prevention');
return null;
}
// Check if account is locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new Error('Account temporarily locked. Try again later.');
}
// Verify password
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
// Increment failed attempts
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: { increment: 1 },
lockedUntil: user.failedAttempts >= 4
? new Date(Date.now() + 15 * 60 * 1000) // Lock for 15 mins after 5 attempts
: null,
},
});
return null;
}
// Reset failed attempts on successful login
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: 0,
lockedUntil: null,
lastLogin: new Date(),
},
});
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
},
pages: {
signIn: '/login',
error: '/auth/error',
},
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
// Handle session update
if (trigger === 'update' && session) {
token.name = session.name;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
async signIn({ user, account }) {
// Log sign-in attempts
console.info('[AUTH] Sign-in attempt', {
userId: user.id,
provider: account?.provider,
timestamp: new Date().toISOString(),
});
return true;
},
},
events: {
async signIn({ user, isNewUser }) {
console.info('[AUTH] Successful sign-in', {
userId: user.id,
isNewUser,
timestamp: new Date().toISOString(),
});
},
async signOut({ token }) {
console.info('[AUTH] Sign-out', {
userId: token?.id,
timestamp: new Date().toISOString(),
});
},
},
});
Type Extensions
typescript
// types/next-auth.d.ts
import 'next-auth';
declare module 'next-auth' {
interface User {
role?: string;
}
interface Session {
user: {
id: string;
role: string;
email: string;
name?: string | null;
};
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
}
}
Password Security
Argon2 Password Hashing
typescript
// lib/security/password.ts
import argon2 from 'argon2';
// Argon2id configuration (OWASP recommended)
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel threads
hashLength: 32, // 32 bytes output
};
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
// Check if password needs rehash (after configuration change)
export async function needsRehash(hash: string): Promise<boolean> {
return argon2.needsRehash(hash, ARGON2_OPTIONS);
}
Password Strength Validation
typescript
// lib/validations/password.ts
import { z } from 'zod';
// Common passwords list (abbreviated - use full list in production)
const COMMON_PASSWORDS = new Set([
'password', '123456', 'qwerty', 'admin', 'letmein',
'welcome', 'monkey', 'dragon', 'master', 'password1',
]);
export const passwordSchema = z
.string()
.min(12, 'Password must be at least 12 characters')
.max(128, 'Password is too long')
.refine(
(password) => /[A-Z]/.test(password),
'Password must contain at least one uppercase letter'
)
.refine(
(password) => /[a-z]/.test(password),
'Password must contain at least one lowercase letter'
)
.refine(
(password) => /[0-9]/.test(password),
'Password must contain at least one number'
)
.refine(
(password) => /[^A-Za-z0-9]/.test(password),
'Password must contain at least one special character'
)
.refine(
(password) => !COMMON_PASSWORDS.has(password.toLowerCase()),
'This password is too common'
)
.refine(
(password) => !/(.)\1{2,}/.test(password),
'Password cannot contain 3+ repeated characters'
);
export function calculatePasswordStrength(password: string): {
score: number;
feedback: string[];
} {
let score = 0;
const feedback: string[] = [];
// Length bonus
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
if (password.length >= 20) score += 1;
// Character variety
if (/[a-z]/.test(password)) score += 1;
if (/[A-Z]/.test(password)) score += 1;
if (/[0-9]/.test(password)) score += 1;
if (/[^A-Za-z0-9]/.test(password)) score += 2;
// Deductions
if (/(.)\1{2,}/.test(password)) {
score -= 1;
feedback.push('Avoid repeated characters');
}
if (/^[A-Za-z]+$/.test(password)) {
feedback.push('Add numbers or symbols');
}
return {
score: Math.max(0, Math.min(10, score)),
feedback,
};
}
Secure Password Reset
typescript
// lib/security/password-reset.ts
import { randomBytes } from 'crypto';
import { prisma } from '@/lib/prisma';
export async function createPasswordResetToken(
email: string
): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
// Return null but don't reveal user existence
return null;
}
// Generate secure token
const token = randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Store hashed token
const hashedToken = await hashToken(token);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
token: hashedToken,
expires,
},
});
// Delete any old tokens for this user
await prisma.passwordResetToken.deleteMany({
where: {
userId: user.id,
expires: { lt: new Date() },
},
});
return token;
}
export async function validatePasswordResetToken(
token: string
): Promise<string | null> {
const hashedToken = await hashToken(token);
const resetToken = await prisma.passwordResetToken.findFirst({
where: {
token: hashedToken,
expires: { gt: new Date() },
},
include: { user: true },
});
return resetToken?.userId || null;
}
async function hashToken(token: string): Promise<string> {
const { createHash } = await import('crypto');
return createHash('sha256').update(token).digest('hex');
}
Session Management
Secure Session Configuration
typescript
// lib/session/config.ts
export const SESSION_CONFIG = {
// Maximum session age
maxAge: 24 * 60 * 60, // 24 hours
// Idle timeout (extend on activity)
idleTimeout: 30 * 60, // 30 minutes
// Secure cookie settings
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
},
// Session regeneration on privilege change
regenerateOnRoleChange: true,
};
Session Activity Tracking
typescript
// lib/session/activity.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function recordSessionActivity(
sessionId: string,
userId: string
): Promise<void> {
const key = `session:${sessionId}`;
await redis.hmset(key, {
lastActivity: Date.now(),
userId,
});
await redis.expire(key, SESSION_CONFIG.maxAge);
}
export async function checkSessionValidity(
sessionId: string
): Promise<boolean> {
const key = `session:${sessionId}`;
const session = await redis.hgetall(key);
if (!session || !session.lastActivity) {
return false;
}
const lastActivity = Number(session.lastActivity);
const idleTime = Date.now() - lastActivity;
// Check idle timeout
if (idleTime > SESSION_CONFIG.idleTimeout * 1000) {
await redis.del(key);
return false;
}
return true;
}
export async function invalidateAllUserSessions(
userId: string
): Promise<void> {
// Scan for all sessions belonging to user
const pattern = 'session:*';
let cursor = 0;
do {
const [nextCursor, keys] = await redis.scan(cursor, {
match: pattern,
count: 100,
});
cursor = Number(nextCursor);
for (const key of keys) {
const session = await redis.hget(key, 'userId');
if (session === userId) {
await redis.del(key);
}
}
} while (cursor !== 0);
}
Access Control
Role-Based Access Control (RBAC)
typescript
// lib/security/rbac.ts
export type Role = 'user' | 'moderator' | 'admin' | 'superadmin';
export type Permission =
| 'read:profile'
| 'write:profile'
| 'read:users'
| 'write:users'
| 'delete:users'
| 'read:admin'
| 'write:admin'
| 'manage:system';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
user: ['read:profile', 'write:profile'],
moderator: ['read:profile', 'write:profile', 'read:users'],
admin: [
'read:profile', 'write:profile',
'read:users', 'write:users',
'read:admin', 'write:admin',
],
superadmin: [
'read:profile', 'write:profile',
'read:users', 'write:users', 'delete:users',
'read:admin', 'write:admin',
'manage:system',
],
};
export function hasPermission(role: Role, permission: Permission): boolean {
const permissions = ROLE_PERMISSIONS[role] || [];
return permissions.includes(permission);
}
export function requirePermission(role: Role, permission: Permission): void {
if (!hasPermission(role, permission)) {
throw new Error(`Permission denied: ${permission}`);
}
}
Protected Server Actions
typescript
// lib/security/auth-guard.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { hasPermission, Permission, Role } from './rbac';
export async function requireAuth() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return session;
}
export async function requireRole(allowedRoles: Role[]) {
const session = await requireAuth();
if (!allowedRoles.includes(session.user.role as Role)) {
throw new Error('Access denied');
}
return session;
}
export async function requirePermissionGuard(permission: Permission) {
const session = await requireAuth();
if (!hasPermission(session.user.role as Role, permission)) {
throw new Error('Permission denied');
}
return session;
}
// Usage in Server Actions
// app/actions/admin.ts
'use server';
import { requirePermissionGuard } from '@/lib/security/auth-guard';
export async function deleteUser(userId: string) {
const session = await requirePermissionGuard('delete:users');
// Log the action
console.info('[ADMIN_ACTION] Delete user', {
adminId: session.user.id,
targetUserId: userId,
timestamp: new Date().toISOString(),
});
// Proceed with deletion
await prisma.user.delete({ where: { id: userId } });
}
Resource-Level Access Control
typescript
// lib/security/resource-guard.ts
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
export async function canAccessResource(
resourceType: 'post' | 'project' | 'comment',
resourceId: string,
action: 'read' | 'write' | 'delete'
): Promise<boolean> {
const session = await auth();
if (!session?.user) return false;
const userId = session.user.id;
const role = session.user.role;
// Admins can access everything
if (role === 'admin' || role === 'superadmin') {
return true;
}
// Check ownership
switch (resourceType) {
case 'post': {
const post = await prisma.post.findUnique({
where: { id: resourceId },
select: { authorId: true, published: true },
});
if (!post) return false;
// Read access for published posts
if (action === 'read' && post.published) return true;
// Write/delete requires ownership
return post.authorId === userId;
}
case 'project': {
const project = await prisma.project.findUnique({
where: { id: resourceId },
select: { ownerId: true, members: { select: { userId: true } } },
});
if (!project) return false;
const isMember = project.members.some(m => m.userId === userId);
const isOwner = project.ownerId === userId;
if (action === 'read') return isMember || isOwner;
if (action === 'write') return isMember || isOwner;
if (action === 'delete') return isOwner;
return false;
}
default:
return false;
}
}
Multi-Factor Authentication
TOTP Implementation
typescript
// lib/security/mfa.ts
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { prisma } from '@/lib/prisma';
import { encrypt, decrypt } from '@/lib/security/encryption';
// Configure TOTP
authenticator.options = {
step: 30,
window: 1,
};
export async function setupMFA(userId: string): Promise<{
secret: string;
qrCodeUrl: string;
}> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');
// Generate secret
const secret = authenticator.generateSecret();
// Create TOTP URL
const otpauth = authenticator.keyuri(
user.email,
'YourApp',
secret
);
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(otpauth);
// Store encrypted secret (pending verification)
const encryptedSecret = await encrypt(secret);
await prisma.user.update({
where: { id: userId },
data: { pendingMfaSecret: encryptedSecret },
});
return { secret, qrCodeUrl };
}
export async function verifyAndEnableMFA(
userId: string,
token: string
): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.pendingMfaSecret) return false;
const secret = await decrypt(user.pendingMfaSecret);
const isValid = authenticator.verify({ token, secret });
if (isValid) {
// Move to active MFA
await prisma.user.update({
where: { id: userId },
data: {
mfaSecret: user.pendingMfaSecret,
mfaEnabled: true,
pendingMfaSecret: null,
},
});
}
return isValid;
}
export async function verifyMFAToken(
userId: string,
token: string
): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.mfaSecret || !user.mfaEnabled) return false;
const secret = await decrypt(user.mfaSecret);
return authenticator.verify({ token, secret });
}
export async function disableMFA(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
mfaSecret: null,
mfaEnabled: false,
},
});
}
MFA Login Flow
typescript
// app/actions/mfa-login.ts
'use server';
import { signIn } from '@/auth';
import { verifyMFAToken } from '@/lib/security/mfa';
import { cookies } from 'next/headers';
export async function loginWithMFA(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const mfaToken = formData.get('mfaToken') as string | null;
// First, verify credentials
const user = await verifyCredentials(email, password);
if (!user) {
return { error: 'Invalid credentials' };
}
// Check if MFA is required
if (user.mfaEnabled) {
if (!mfaToken) {
// Store temporary session for MFA step
const cookieStore = await cookies();
cookieStore.set('mfa_pending', user.id, {
httpOnly: true,
secure: true,
maxAge: 300, // 5 minutes
});
return { requiresMFA: true };
}
// Verify MFA token
const isValidMFA = await verifyMFAToken(user.id, mfaToken);
if (!isValidMFA) {
return { error: 'Invalid MFA code' };
}
}
// Complete sign-in
await signIn('credentials', {
email,
password,
redirect: false,
});
return { success: true };
}
Brute Force Protection
Login Attempt Tracking
typescript
// lib/security/brute-force.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60; // 15 minutes
const ATTEMPT_WINDOW = 60 * 60; // 1 hour
export async function recordLoginAttempt(
identifier: string,
success: boolean
): Promise<{ locked: boolean; attemptsRemaining: number }> {
const key = `login:attempts:${identifier}`;
if (success) {
// Clear attempts on successful login
await redis.del(key);
return { locked: false, attemptsRemaining: MAX_ATTEMPTS };
}
// Increment failed attempts
const attempts = await redis.incr(key);
await redis.expire(key, ATTEMPT_WINDOW);
if (attempts >= MAX_ATTEMPTS) {
// Lock the account
const lockKey = `login:locked:${identifier}`;
await redis.set(lockKey, '1', { ex: LOCKOUT_DURATION });
return { locked: true, attemptsRemaining: 0 };
}
return {
locked: false,
attemptsRemaining: MAX_ATTEMPTS - attempts,
};
}
export async function isAccountLocked(identifier: string): Promise<boolean> {
const lockKey = `login:locked:${identifier}`;
const locked = await redis.get(lockKey);
return locked === '1';
}
export async function getLockoutRemaining(identifier: string): Promise<number> {
const lockKey = `login:locked:${identifier}`;
return await redis.ttl(lockKey);
}
OAuth Security
Secure OAuth Configuration
typescript
// OAuth provider security settings
// auth.ts
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Request minimal scopes
authorization: {
params: {
scope: 'openid email profile',
prompt: 'select_account',
},
},
// Verify email domain for organizational accounts
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
}),
// Account linking protection in callbacks
callbacks: {
async signIn({ user, account, profile }) {
// Prevent account takeover via OAuth
if (account?.provider !== 'credentials') {
const existingUser = await prisma.user.findUnique({
where: { email: user.email! },
include: { accounts: true },
});
if (existingUser && existingUser.accounts.length === 0) {
// Existing user without OAuth - require password verification
return '/auth/link-account';
}
}
return true;
},
},
JWT Security
Secure JWT Configuration
typescript
// lib/security/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const JWT_ISSUER = 'your-app';
const JWT_AUDIENCE = 'your-app-users';
export async function createSecureJWT(payload: {
sub: string;
role: string;
[key: string]: unknown;
}): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('1h')
.sign(JWT_SECRET);
}
export async function verifySecureJWT(token: string): Promise<{
valid: boolean;
payload?: Record<string, unknown>;
error?: string;
}> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return { valid: true, payload: payload as Record<string, unknown> };
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Invalid token',
};
}
}
Best Practices
Authentication Security Checklist
- •
Password Security
- •Use Argon2id for hashing
- •Enforce strong password policies
- •Implement secure password reset
- •
Session Management
- •Use secure cookie flags
- •Implement idle timeout
- •Regenerate session on privilege change
- •
Access Control
- •Check permissions on every request
- •Use principle of least privilege
- •Implement resource-level checks
- •
Multi-Factor Authentication
- •Offer TOTP as second factor
- •Provide backup codes
- •Allow MFA for sensitive operations
- •
Brute Force Protection
- •Rate limit login attempts
- •Implement progressive delays
- •Lock accounts after threshold
Dependencies to Install
bash
npm install argon2 otplib qrcode jose @upstash/redis npm install -D @types/qrcode
Environment Variables
env
AUTH_SECRET=32-character-random-secret JWT_SECRET=another-32-character-secret GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret
Prisma Schema Extensions
prisma
model User {
id String @id @default(cuid())
email String @unique
password String?
role String @default("user")
mfaEnabled Boolean @default(false)
mfaSecret String?
pendingMfaSecret String?
failedAttempts Int @default(0)
lockedUntil DateTime?
lastLogin DateTime?
accounts Account[]
}
model PasswordResetToken {
id String @id @default(cuid())
userId String
token String @unique
expires DateTime
user User @relation(fields: [userId], references: [id])
}