On this page10 sections
Security isn't an afterthought—it's a foundation. For SaaS applications handling customer data, a single vulnerability can destroy trust and potentially your business.
This guide covers essential security practices for Next.js SaaS applications, from authentication to deployment.
Authentication Security
1. Use Established Authentication Libraries
Never roll your own authentication. Use battle-tested libraries:
// auth.ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 12
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
}
}
});2. Implement Secure Session Management
// auth.ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL!],
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24
}
});3. Rate Limit Authentication Endpoints
Protect against brute force attacks:
// auth.ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
rateLimit: {
enabled: true,
window: 60,
max: 10
}
});Better Auth applies stricter built-in limits to sensitive endpoints. Its default in-memory storage is not shared reliably across serverless instances, so configure database, secondary, or custom storage for distributed production deployments.
4. Secure Password Storage
If handling passwords directly, use proper hashing:
import { hash, verify } from '@node-rs/argon2';
// Hashing with Argon2id (recommended over bcrypt)
export async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return verify(hashedPassword, password);
}Authorization & Access Control
1. Always Verify Permissions Server-Side
Never trust client-side authorization checks:
// BAD: Client-side check only
if (user.role === 'admin') {
return <AdminPanel />;
}
// GOOD: Server-side verification
export async function AdminPage() {
const session = await auth();
// Verify from database, not session
const user = await db.user.findUnique({
where: { id: session?.user?.id },
select: { role: true }
});
if (user?.role !== 'ADMIN') {
redirect('/unauthorized');
}
return <AdminPanel />;
}2. Implement Resource-Level Authorization
Check ownership for every resource access:
// actions/documents.ts
export async function deleteDocument(documentId: string) {
const session = await auth();
if (!session?.user?.id) {
throw new Error('Unauthorized');
}
// Verify ownership before action
const document = await db.document.findFirst({
where: {
id: documentId,
userId: session.user.id // Critical: ownership check
}
});
if (!document) {
throw new Error('Document not found'); // Same error as not found
}
await db.document.delete({ where: { id: documentId } });
}3. Use RBAC for Complex Permissions
// lib/permissions.ts
type Permission =
| 'document:create'
| 'document:read'
| 'document:update'
| 'document:delete'
| 'user:manage';
const rolePermissions: Record<string, Permission[]> = {
admin: [
'document:create',
'document:read',
'document:update',
'document:delete',
'user:manage'
],
editor: ['document:create', 'document:read', 'document:update'],
viewer: ['document:read']
};
export function checkPermission(role: string, permission: Permission): boolean {
return rolePermissions[role]?.includes(permission) ?? false;
}
// Usage in server action
export async function updateDocument(id: string, data: UpdateData) {
const session = await auth();
const membership = await getMembership(session?.user?.id);
if (!checkPermission(membership.role, 'document:update')) {
throw new Error('Insufficient permissions');
}
// Proceed with update
}Input Validation & Sanitization
1. Validate All Input with Zod
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
// Prevent common injection patterns
website: z
.string()
.url()
.optional()
.refine((url) => !url || !url.includes('javascript:'), {
message: 'Invalid URL protocol'
})
});
export async function createUser(formData: FormData) {
const validated = createUserSchema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
website: formData.get('website')
});
if (!validated.success) {
return { error: validated.error.flatten() };
}
// Safe to use validated.data
}2. Sanitize HTML Content
If allowing rich text, sanitize it:
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target'],
ALLOW_DATA_ATTR: false
});
}3. Parameterize Database Queries
Prisma and Drizzle protect against SQL injection by default, but be careful with raw queries:
// BAD: String concatenation
const users = await db.$queryRaw`
SELECT * FROM users WHERE email = '${email}'
`;
// GOOD: Parameterized query
const users = await db.$queryRaw`
SELECT * FROM users WHERE email = ${email}
`;API Security
1. Implement CORS Properly
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: process.env.ALLOWED_ORIGIN || 'https://yourdomain.com'
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS'
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization'
}
]
}
];
}
};2. Validate API Request Origins
// app/api/sensitive/route.ts
import { headers } from 'next/headers';
export async function POST(request: Request) {
const requestHeaders = await headers();
const origin = requestHeaders.get('origin');
const allowedOrigins = [
'https://yourdomain.com',
'https://app.yourdomain.com'
];
if (!origin || !allowedOrigins.includes(origin)) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// Process request
}3. Use CSRF Protection
Server Actions in Next.js have built-in CSRF protection, but for custom APIs:
// lib/csrf.ts
import { randomBytes } from 'crypto';
import { cookies } from 'next/headers';
export async function generateCsrfToken(): Promise<string> {
const token = randomBytes(32).toString('hex');
const cookieStore = await cookies();
cookieStore.set('csrf-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
return token;
}
export async function validateCsrfToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get('csrf-token')?.value;
return token === storedToken;
}Data Protection
1. Encrypt Sensitive Data at Rest
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; // 32 bytes
const ALGORITHM = 'aes-256-gcm';
export function encrypt(text: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv(
ALGORITHM,
Buffer.from(ENCRYPTION_KEY, 'hex'),
iv
);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decrypt(encryptedData: string): string {
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
const decipher = createDecipheriv(
ALGORITHM,
Buffer.from(ENCRYPTION_KEY, 'hex'),
Buffer.from(ivHex, 'hex')
);
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}2. Never Log Sensitive Data
// Even better: Use structured logging with redaction
import pino from 'pino';
// BAD
console.log('User login:', { email, password });
// GOOD
console.log('User login:', { email, password: '[REDACTED]' });
const logger = pino({
redact: ['password', 'token', 'apiKey', '*.password', '*.token']
});3. Implement Data Retention Policies
// cron/cleanup.ts
export async function cleanupOldData() {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Delete old audit logs
await db.auditLog.deleteMany({
where: {
createdAt: { lt: thirtyDaysAgo }
}
});
// Anonymize deleted user data
await db.user.updateMany({
where: {
deletedAt: { lt: thirtyDaysAgo }
},
data: {
email: db.raw("CONCAT(id, '@deleted.local')"),
name: 'Deleted User'
}
});
}Security Headers
Configure Security Headers
// next.config.js
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
},
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim()
}
];
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders
}
];
}
};Environment Variable Security
1. Never Expose Server Secrets to Client
// DANGEROUS: Accessible on client
NEXT_PUBLIC_DATABASE_URL=... // Never do this!
// SAFE: Server-only
DATABASE_URL=...
STRIPE_SECRET_KEY=...2. Validate Environment Variables
// env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_')
});
export const env = envSchema.parse(process.env);Security Monitoring
1. Log Security Events
// lib/audit.ts
export async function logSecurityEvent(event: {
type: 'login' | 'logout' | 'permission_denied' | 'suspicious_activity';
userId?: string;
ip: string;
userAgent: string;
details: Record<string, unknown>;
}) {
await db.securityLog.create({
data: {
type: event.type,
userId: event.userId,
ip: event.ip,
userAgent: event.userAgent,
details: event.details,
timestamp: new Date()
}
});
// Alert on suspicious activity
if (event.type === 'suspicious_activity') {
await sendAlertToSecurityTeam(event);
}
}2. Implement Anomaly Detection
// Check for suspicious patterns
export async function detectAnomalies(userId: string, ip: string) {
const recentLogins = await db.securityLog.count({
where: {
userId,
type: 'login',
timestamp: { gte: new Date(Date.now() - 60 * 60 * 1000) } // Last hour
}
});
if (recentLogins > 10) {
await logSecurityEvent({
type: 'suspicious_activity',
userId,
ip,
userAgent: '',
details: { reason: 'excessive_logins', count: recentLogins }
});
}
}Security Checklist
Before deploying, verify:
- Authentication uses an established, maintained library such as Better Auth
- All routes verify authentication server-side
- Resource access checks ownership
- Input validation on all user data
- SQL injection prevented (parameterized queries)
- XSS prevented (output encoding, CSP)
- CSRF protection enabled
- Security headers configured
- Sensitive data encrypted at rest
- Environment variables validated
- Rate limiting on auth endpoints
- Security logging implemented
- Dependencies up to date
Conclusion
Security in SaaS isn't optional—it's the foundation of customer trust. The practices in this guide provide defense in depth:
- Authentication: Use established libraries, secure sessions
- Authorization: Server-side checks, ownership verification
- Input Validation: Zod schemas, sanitization
- API Security: CORS, CSRF, rate limiting
- Data Protection: Encryption, retention policies
- Monitoring: Audit logs, anomaly detection
Security is a continuous process. Regularly audit your code, keep dependencies updated, and stay informed about new vulnerabilities.
Want a maintained foundation? Achromatic includes Better Auth and documented server-side authorization patterns. Add product-specific rate limits and security controls for your own threat model before launch.



