
auth-patterns
Authentication security patterns and standards for NextAuth.js v5. Use when implementing or reviewin
Authentication Patterns and Standards
Security patterns and best practices for implementing authentication in the Posterns MVP using NextAuth.js v5 (Auth.js).
Security Requirements
Password Security
Hashing:
- Algorithm: bcrypt
- Salt rounds: 12 (minimum)
- File:
lib/auth/password.ts
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
Password Requirements:
- Minimum 8 characters
- At least one uppercase letter (A-Z)
- At least one lowercase letter (a-z)
- At least one number (0-9)
- Validated with Zod schema
Validation Pattern:
import { z } from 'zod';
const password = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number');
Token Security
Token Generation:
- Algorithm:
crypto.randomBytes(32) - Format: Hexadecimal string
- File:
lib/auth/tokens.ts
import crypto from 'crypto';
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export function generatePasswordResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
Token Expiry:
- Email verification: 24 hours
- Password reset: 1 hour
export const TOKEN_EXPIRY = {
EMAIL_VERIFICATION: 24 * 60 * 60 * 1000, // 24 hours
PASSWORD_RESET: 60 * 60 * 1000, // 1 hour
} as const;
export function getTokenExpiry(
type: 'EMAIL_VERIFICATION' | 'PASSWORD_RESET'
): Date {
return new Date(Date.now() + TOKEN_EXPIRY[type]);
}
Token Usage:
- One-time use: Delete token after successful use
- Expiry check: Validate
expiresfield before accepting token - Storage: Store in
VerificationTokentable with type field
Rate Limiting
Endpoints and Limits:
- Login: 5 requests per minute
- Register: 3 requests per minute
- Forgot Password: 3 requests per minute
- Verify Email: 5 requests per minute
- Resend Verification: 2 requests per minute
Implementation (lib/rate-limit.ts):
export const authRateLimiter = {
login: (identifier: string) =>
rateLimit(`login:${identifier}`, 5, 60000),
register: (identifier: string) =>
rateLimit(`register:${identifier}`, 3, 60000),
forgotPassword: (identifier: string) =>
rateLimit(`forgot:${identifier}`, 3, 60000),
verifyEmail: (identifier: string) =>
rateLimit(`verify:${identifier}`, 5, 60000),
resendVerification: (identifier: string) =>
rateLimit(`resend:${identifier}`, 2, 60000),
};
Client Identification:
export function getClientIdentifier(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
return ip;
}
Rate Limit Response:
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
);
}
Authentication Flows
Registration Flow
- User submits: name, email, password, confirmPassword
- Validate input: Zod schema validation
- Normalize email: Convert to lowercase
- Check existing user: Query database for email
- Hash password: bcrypt with 12 salt rounds
- Create user: Insert into database
- Generate token: 32-byte random hex string
- Create verification token: Store in VerificationToken table
- Send email: Email verification link via Resend
- Return success: Prompt user to check email
API Route Pattern (app/api/auth/register/route.ts):
export async function POST(request: Request) {
try {
// Rate limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = authRateLimiter.register(clientId);
if (!rateLimitResult.success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
);
}
const body = await request.json();
// Validate input
const validated = registerSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json(
{ error: 'Invalid input', details: validated.error.flatten() },
{ status: 400 }
);
}
const { name, email, password } = validated.data;
const normalizedEmail = email.toLowerCase();
// Check existing user
const existingUser = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existingUser) {
return NextResponse.json(
{ error: 'An account with this email already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const user = await prisma.user.create({
data: {
name,
email: normalizedEmail,
password: hashedPassword,
},
});
// Create verification token
const token = generateVerificationToken();
await prisma.verificationToken.create({
data: {
identifier: normalizedEmail,
token,
expires: getTokenExpiry('EMAIL_VERIFICATION'),
type: 'EMAIL_VERIFICATION',
},
});
// Send verification email
const emailResult = await sendVerificationEmail(normalizedEmail, token);
if (!emailResult.success) {
console.error('Failed to send verification email:', emailResult.error);
return NextResponse.json(
{
message: 'Account created but verification email could not be sent.',
userId: user.id,
emailFailed: true,
},
{ status: 201 }
);
}
return NextResponse.json(
{
message: 'Account created successfully. Please check your email.',
userId: user.id,
},
{ status: 201 }
);
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'An error occurred during registration' },
{ status: 500 }
);
}
}
Login Flow
- User submits: email, password, rememberMe (optional)
- Rate limit check: 5 requests per minute
- Validate input: Zod schema validation
- Normalize email: Convert to lowercase
- Find user: Query database for email
- Verify password: bcrypt.compare with stored hash
- Check email verified: Ensure user verified email
- Create session: NextAuth session with user data
- Return success: Redirect to dashboard
Credentials Provider Pattern (lib/auth.config.ts):
import Credentials from 'next-auth/providers/credentials';
import { loginSchema } from '@/lib/validations/auth';
import { verifyPassword } from '@/lib/auth/password';
export default {
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Validate input
const validated = loginSchema.safeParse(credentials);
if (!validated.success) {
throw new Error('Invalid credentials');
}
const { email, password } = validated.data;
const normalizedEmail = email.toLowerCase();
// Find user
const user = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (!user || !user.password) {
throw new Error('Invalid credentials');
}
// Verify password
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
throw new Error('Invalid credentials');
}
// Check email verified
if (!user.emailVerified) {
throw new Error('Please verify your email before logging in');
}
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
};
},
}),
],
};
Email Verification Flow
- User clicks link: From verification email with token parameter
- Rate limit check: 5 requests per minute
- Validate token: Check token exists and not expired
- Update user: Set
emailVerifiedto current timestamp - Delete token: Remove used token from database
- Return success: Show success message, allow login
Password Reset Flow
- User requests reset: Submits email address
- Rate limit check: 3 requests per minute
- Find user: Query database for email
- Generate token: 32-byte random hex string
- Create reset token: Store in VerificationToken table with 1-hour expiry
- Send email: Password reset link via Resend
- User clicks link: Token in URL parameter
- User submits new password: With token from URL
- Validate token: Check exists and not expired
- Hash new password: bcrypt with 12 salt rounds
- Update user: Replace old password hash
- Delete token: Remove used token
- Invalidate sessions: Force re-login
- Return success: Redirect to login
Email Service Integration
Resend API Configuration
Environment Variables:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxx
EMAIL_FROM=noreply@posterns.lv
Email Sending Pattern (lib/email/index.ts):
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendVerificationEmail(
email: string,
token: string
): Promise<{ success: boolean; error?: string }> {
try {
const verificationUrl = `${process.env.NEXTAUTH_URL}/verify-email?token=${token}`;
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: email,
subject: 'Verify your email address',
html: `
<p>Please verify your email address by clicking the link below:</p>
<a href="${verificationUrl}">Verify Email</a>
<p>This link will expire in 24 hours.</p>
`,
});
return { success: true };
} catch (error) {
console.error('Email send error:', error);
return { success: false, error: String(error) };
}
}
Session Management
NextAuth.js v5 Session Configuration
Auth Configuration (lib/auth.ts):
import NextAuth from 'next-auth';
import authConfig from './auth.config';
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.emailVerified = user.emailVerified;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.emailVerified = token.emailVerified as Date | null;
}
return session;
},
},
});
Session Access Patterns:
Server Components:
import { auth } from '@/lib/auth';
export default async function Page() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <div>Welcome {session.user.name}</div>;
}
API Routes:
import { auth } from '@/lib/auth';
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// User is authenticated
const userId = session.user.id;
// ... handle request
}
Client Components:
'use client';
import { useSession } from 'next-auth/react';
export function UserProfile() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div>Loading...</div>;
}
if (!session?.user) {
return <div>Not logged in</div>;
}
return <div>Hello {session.user.name}</div>;
}
Security Best Practices
Input Validation
Always use Zod schemas:
import { z } from 'zod';
// Validate ALL user inputs
const validated = schema.safeParse(userInput);
if (!validated.success) {
return error response;
}
// Use validated.data (never use userInput directly)
SQL Injection Prevention
Use Prisma parameterization (never raw SQL unless absolutely necessary):
// Good - Prisma handles parameterization
const user = await prisma.user.findUnique({
where: { email: userEmail },
});
// Bad - Vulnerable to SQL injection
const user = await prisma.$queryRaw`
SELECT * FROM User WHERE email = ${userEmail}
`;
XSS Prevention
React auto-escapes by default, but:
- Never use
dangerouslySetInnerHTMLwith user input - Validate all inputs with Zod
- Sanitize before storing in database
CSRF Protection
NextAuth.js provides built-in CSRF protection:
- CSRF tokens automatically included in forms
- State-changing operations use POST/PUT/DELETE (never GET)
- Verify origin header in API routes
Timing Attacks
Use constant-time comparisons for sensitive operations:
// Good - bcrypt.compare is constant-time
const isValid = await bcrypt.compare(password, hashedPassword);
// Good - crypto.timingSafeEqual for token comparison
import crypto from 'crypto';
function compareTokens(a: string, b: string): boolean {
const bufferA = Buffer.from(a, 'hex');
const bufferB = Buffer.from(b, 'hex');
if (bufferA.length !== bufferB.length) {
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
Error Messages
Don't reveal if email exists:
// Good - Generic message
if (!user || !isPasswordValid) {
throw new Error('Invalid credentials');
}
// Bad - Reveals email existence
if (!user) {
throw new Error('Email not found');
}
if (!isPasswordValid) {
throw new Error('Wrong password');
}
Testing Authentication
Manual Testing Checklist
- Register new user with valid data
- Try registering with existing email (should fail)
- Try registering with weak password (should fail)
- Verify email verification link works
- Try logging in before email verification (should fail)
- Login with correct credentials
- Login with incorrect password (should fail)
- Test rate limiting (make 10 rapid requests)
- Request password reset
- Reset password with valid token
- Try reusing password reset token (should fail)
- Try using expired token (should fail after 1 hour)
- Test "remember me" functionality
- Test session expiry
- Test logout functionality
Security Audit Checklist
- Passwords are hashed with bcrypt (12 rounds minimum)
- Tokens are generated with crypto.randomBytes (32 bytes minimum)
- Rate limiting is applied to all auth endpoints
- Email addresses are normalized (lowercase)
- Email verification is required
- Password requirements are enforced
- Error messages don't reveal user existence
- No secrets in client-side code
- Environment variables used for sensitive config
- HTTPS enforced in production
- CSRF protection enabled
- Session timeout configured
- No SQL injection vulnerabilities
- XSS prevention implemented
- Proper input validation with Zod
Common Vulnerabilities to Avoid
1. Weak Password Storage
❌ Don't:
- Store plaintext passwords
- Use weak hashing (MD5, SHA1)
- Use insufficient salt rounds
✅ Do:
- Use bcrypt with 12+ salt rounds
- Never store plaintext passwords
2. Insufficient Rate Limiting
❌ Don't:
- Allow unlimited login attempts
- Skip rate limiting on sensitive endpoints
✅ Do:
- Implement rate limiting on all auth endpoints
- Use IP-based identification
- Return 429 status for rate limit exceeded
3. Predictable Tokens
❌ Don't:
- Use sequential IDs for tokens
- Use timestamps or guessable patterns
✅ Do:
- Use crypto.randomBytes (32+ bytes)
- Use sufficient entropy
4. Missing Email Verification
❌ Don't:
- Allow unverified users full access
- Skip email verification
✅ Do:
- Require email verification before login
- Set reasonable token expiry (24 hours)
5. Exposing User Existence
❌ Don't:
- "Email not found"
- "Password incorrect"
- Different error messages for different failures
✅ Do:
- "Invalid credentials" for all login failures
- Same response time regardless of failure reason