Friday, April 18th 2025 · 3 min read
SaaS Security Best Practices for Next.js Applications
Essential security practices for building secure SaaS applications in Next.js. Covers authentication, authorization, data protection, API security, and common vulnerabilities.
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:
// Use Auth.js (NextAuth.js) or Better Auth
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
callbacks: {
// Properly type and validate session data
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id
}
})
}
});2. Implement Secure Session Management
// auth.ts configuration
export const authConfig = {
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60 // 30 days
},
cookies: {
sessionToken: {
name: '__Secure-next-auth.session-token',
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production'
}
}
}
};3. Rate Limit Authentication Endpoints
Protect against brute force attacks:
// middleware.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 attempts per minute
analytics: true
});
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/auth')) {
const ip = request.ip ?? '127.0.0.1';
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
}
return NextResponse.next();
}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 origin = headers().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 function generateCsrfToken(): string {
const token = randomBytes(32).toString('hex');
cookies().set('csrf-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
return token;
}
export function validateCsrfToken(token: string): boolean {
const storedToken = cookies().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 established library (Auth.js, 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 security built-in from day one? Achromatic includes authentication, authorization, rate limiting, and security headers pre-configured—so you can focus on building features, not patching vulnerabilities.
Related Articles
React DoS & Source Code Exposure - Starter Kits Updated
Two new React Server Components vulnerabilities discovered. All Achromatic starter kits updated to patched versions.
How to Implement Metered Billing with Stripe in Next.js
Learn how to implement usage-based metered billing with Stripe in your Next.js SaaS. Covers metered subscriptions, usage reporting, and real-time tracking.
New Achromatic Starter Kits Are Here
Next.js 16, React 19, Better Auth, tRPC with Prisma or Drizzle ORM. Ship your SaaS faster than ever.