Vercel Security Best Practices After CVE-2025-55182
The disclosure of CVE-2025-55182 on December 11, 2025 prompted a security review across our Next.js applications. While we weren't directly affected, the incident highlighted gaps in our security posture that we've since addressed. This post documents the security hardening measures we implemented and the patterns we now follow for all Vercel deployments.
The Wake-Up Call
Security vulnerabilities in popular frameworks aren't new, but CVE-2025-55182 served as a reminder that relying solely on framework defaults isn't sufficient. The vulnerability affected how certain server-side data could be exposed under specific conditions, emphasizing the importance of defense-in-depth strategies.
Our response involved three phases: immediate patching, security audit, and implementing long-term hardening measures. The practices below emerged from that process.
Environment Variables: The Foundation
Environment variable security is where most applications have gaps. The principle is simple: never expose secrets to the client, and never commit secrets to version control. The implementation is where things go wrong.
Server-Only Secrets
In Next.js, only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else remains server-side. This is correct behavior, but it requires discipline:
// .env.local
DATABASE_URL=postgres://user:password@localhost:5432/mydb
API_SECRET_KEY=sk_live_abc123
STRIPE_SECRET_KEY=sk_live_stripe_abc123
// These are exposed to the client - never put secrets here
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_ANALYTICS_ID=UA-12345678-1Vercel Sensitive Environment Variables
Vercel introduced sensitive environment variable policies that we now enable on all projects. When a variable is marked as sensitive:
- It cannot be read by team members after creation
- It's only available in Production and Preview environments
- It's protected from accidental exposure in build logs
To enable:
- Navigate to Project Settings → Environment Variables
- Remove existing sensitive variables
- Re-add them with the "Sensitive" toggle enabled
- Enable "Sensitive Environment Variables" team policy
Runtime Loading Pattern
For applications that need runtime environment loading (important for containerized deployments), use this pattern:
// lib/config.ts
interface Config {
databaseUrl: string;
apiKey: string;
stripeKey: string;
}
function loadConfig(): Config {
const required = ['DATABASE_URL', 'API_SECRET_KEY', 'STRIPE_SECRET_KEY'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
return {
databaseUrl: process.env.DATABASE_URL!,
apiKey: process.env.API_SECRET_KEY!,
stripeKey: process.env.STRIPE_SECRET_KEY!,
};
}
export const config = loadConfig();This fails fast at startup if required variables are missing, rather than failing silently at runtime.
API Route Security
API routes are the attack surface most likely to be exploited. Our hardening includes input validation, rate limiting, and proper error handling.
Input Validation with Zod
Every API route validates its inputs. We use Zod because it provides both runtime validation and TypeScript type inference:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = createUserSchema.parse(body);
// validated is now typed and safe to use
const user = await createUser(validated);
return NextResponse.json({ user }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.issues },
{ status: 400 }
);
}
// Never expose internal errors to clients
console.error('User creation failed:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}Rate Limiting
We implement rate limiting using Vercel KV (Redis) in the proxy:
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { kv } from '@vercel/kv';
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;
async function checkRateLimit(identifier: string): Promise<boolean> {
const key = `rate_limit:${identifier}`;
const current = await kv.incr(key);
if (current === 1) {
await kv.expire(key, Math.ceil(RATE_LIMIT_WINDOW / 1000));
}
return current <= MAX_REQUESTS;
}
export async function proxy(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
const allowed = await checkRateLimit(ip);
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: { 'Retry-After': '60' } }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};Authentication Middleware Pattern
For protected routes, validate authentication in the proxy:
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/register', '/api/health'];
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Skip auth for public paths
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// Check for protected API routes
if (pathname.startsWith('/api')) {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
try {
const user = await verifyToken(token);
// Pass user info to API routes via headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', user.id);
requestHeaders.set('x-user-role', user.role);
return NextResponse.next({
request: { headers: requestHeaders }
});
} catch {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
);
}
}
return NextResponse.next();
}Security Headers Configuration
Proper HTTP security headers prevent entire classes of attacks. We configure these in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next';
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Adjust based on needs
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.your-domain.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
];
const nextConfig: NextConfig = {
headers: async () => [
{
source: '/(.*)',
headers: securityHeaders,
},
],
};
export default nextConfig;The Content-Security-Policy header is the most important but also the most likely to break things. Start with a report-only policy to identify issues:
{
key: 'Content-Security-Policy-Report-Only',
value: "default-src 'self'; report-uri /api/csp-report",
}Server Actions Security
With Next.js 16, Server Actions require additional security considerations. Enable encryption for Server Actions that handle sensitive data:
# Set a persistent encryption key
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-32-char-encryption-key-hereThis ensures Server Action payloads are encrypted, preventing interception of sensitive closures.
Validating Server Action Inputs
Server Actions should validate inputs just like API routes:
// actions/user.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const rawData = {
name: formData.get('name'),
bio: formData.get('bio'),
};
const validated = updateProfileSchema.parse(rawData);
// Safe to use validated data
await db.user.update({
where: { id: getCurrentUserId() },
data: validated,
});
revalidatePath('/profile');
}Data Access Layer Pattern
We implement a Data Access Layer (DAL) that serves as the single point for database access. This layer enforces authorization:
// lib/dal/users.ts
import { getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getUserById(id: string) {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error('Unauthorized');
}
// Users can only read their own data unless admin
if (currentUser.id !== id && currentUser.role !== 'admin') {
throw new Error('Forbidden');
}
return db.user.findUnique({ where: { id } });
}
export async function updateUser(id: string, data: UpdateUserData) {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error('Unauthorized');
}
if (currentUser.id !== id && currentUser.role !== 'admin') {
throw new Error('Forbidden');
}
// Prevent privilege escalation
if (data.role && currentUser.role !== 'admin') {
delete data.role;
}
return db.user.update({ where: { id }, data });
}Server Components and Server Actions call this layer, never the database directly.
Dependency Security
After CVE-2025-55182, we implemented stricter dependency management:
Automated Scanning
# .github/workflows/security.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 0 * * 1' # Weekly
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk test
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}Lock File Verification
We verify the integrity of lock files in CI:
npm ci --ignore-scriptsThe --ignore-scripts flag prevents potentially malicious postinstall scripts from running during CI builds.
Monitoring and Incident Response
Security monitoring is essential for early detection:
Structured Logging
// lib/logger.ts
type LogLevel = 'info' | 'warn' | 'error' | 'security';
interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
requestId?: string;
userId?: string;
metadata?: Record<string, unknown>;
}
function sanitize(data: unknown): unknown {
if (typeof data !== 'object' || data === null) return data;
const sensitiveKeys = ['password', 'token', 'secret', 'key', 'authorization'];
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
sanitized[key] = '[REDACTED]';
} else if (typeof value === 'object') {
sanitized[key] = sanitize(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
export function log(entry: Omit<LogEntry, 'timestamp'>) {
const logEntry: LogEntry = {
...entry,
timestamp: new Date().toISOString(),
metadata: entry.metadata ? sanitize(entry.metadata) as Record<string, unknown> : undefined,
};
console.log(JSON.stringify(logEntry));
}
// Security-specific logging
export function securityLog(message: string, metadata?: Record<string, unknown>) {
log({ level: 'security', message, metadata });
}Security Event Logging
// In your auth functions
export async function login(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user || !await verifyPassword(password, user.passwordHash)) {
securityLog('Failed login attempt', { email, reason: 'invalid_credentials' });
throw new Error('Invalid credentials');
}
securityLog('Successful login', { userId: user.id, email });
return createSession(user);
}Security Checklist
After implementing these measures, we maintain this checklist for all deployments:
- [ ] Environment variables use Vercel Sensitive policy
- [ ] No
NEXT_PUBLIC_prefix on secrets - [ ] All API routes validate inputs with Zod
- [ ] Rate limiting enabled for public endpoints
- [ ] Authentication required for protected routes
- [ ] Security headers configured (CSP, HSTS, etc.)
- [ ] Server Actions encryption key configured
- [ ] Data Access Layer enforces authorization
- [ ] npm audit passes with no high/critical vulnerabilities
- [ ] Security logging enabled and monitored
- [ ] No source maps in production
Security isn't a one-time task. We review this checklist before every major deployment and audit the full configuration quarterly. The patterns described here have prevented several potential issues since we implemented them.
For applications handling sensitive data, consider additional measures like Web Application Firewalls (WAF), penetration testing, and security audits by external firms.