Security Best Practices for Next.js and Supabase Applications
Comprehensive security guide for Next.js and Supabase applications. Learn RLS policies, secret management, API security, authentication hardening, and production security checklist.
Security Best Practices for Next.js and Supabase Applications#
Security is not a feature—it's a foundation. A single security vulnerability can compromise your entire application and your users' data. This comprehensive guide teaches you how to build secure Next.js and Supabase applications that protect user data and maintain trust.
Security Fundamentals#
Before diving into specific techniques, understand these core principles:
Defense in Depth:
- Don't rely on a single security layer
- Implement security at multiple levels: database, API, application, infrastructure
- If one layer fails, others still protect your data
Principle of Least Privilege:
- Grant users only the minimum permissions needed
- Use the anon key for public data, service role key only in server code
- Create specific RLS policies for each user role
Assume Breach:
- Design systems assuming attackers will eventually find vulnerabilities
- Implement monitoring and alerting to detect breaches quickly
- Have an incident response plan
1. Row Level Security (RLS) Policies#
RLS is your most important security tool. It enforces data access policies at the database level, ensuring users can only access authorized data.
Enabling RLS#
-- Enable RLS on a table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Drop existing policies (if any)
DROP POLICY IF EXISTS "Users can view own posts" ON posts;
DROP POLICY IF EXISTS "Users can create posts" ON posts;
DROP POLICY IF EXISTS "Users can update own posts" ON posts;
DROP POLICY IF EXISTS "Users can delete own posts" ON posts;
Basic RLS Policies#
SELECT Policy (Read Access):
CREATE POLICY "Users can view own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
INSERT Policy (Create Access):
CREATE POLICY "Users can create posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
UPDATE Policy (Edit Access):
CREATE POLICY "Users can update own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
DELETE Policy (Delete Access):
CREATE POLICY "Users can delete own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
Advanced RLS Patterns#
Role-Based Access Control:
-- Create roles table
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id),
organization_id UUID REFERENCES organizations(id),
role TEXT CHECK (role IN ('admin', 'editor', 'viewer')),
UNIQUE(user_id, organization_id)
);
-- RLS policy using roles
CREATE POLICY "Users can view organization data based on role"
ON posts FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM roles
WHERE user_id = auth.uid()
)
);
Time-Based Access:
-- Users can only edit posts within 24 hours of creation
CREATE POLICY "Users can edit recent posts"
ON posts FOR UPDATE
USING (
auth.uid() = user_id
AND created_at > NOW() - INTERVAL '24 hours'
);
Hierarchical Access:
-- Admins can view all posts, users can only view own posts
CREATE POLICY "Admins can view all posts"
ON posts FOR SELECT
USING (
EXISTS (
SELECT 1 FROM roles
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
CREATE POLICY "Users can view own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
Testing RLS Policies#
// Test RLS policies in your application
async function testRLSPolicy(userId: string) {
const client = createClient(userId); // Create client as specific user
// This should only return posts owned by userId
const { data: posts, error } = await client
.from('posts')
.select('*');
if (error) {
console.error('RLS policy blocked access:', error);
}
return posts;
}
2. Secret Management#
Environment Variables#
Store secrets in environment variables, never in code:
# .env.local (development only, never commit)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DATABASE_URL=postgresql://user:password@host/database
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=your-jwt-secret
Production Secrets#
For production, use your platform's secrets manager:
Vercel:
# Set secrets in Vercel
vercel env add SUPABASE_SERVICE_ROLE_KEY
vercel env add STRIPE_SECRET_KEY
AWS:
// Fetch secrets from AWS Secrets Manager
import { SecretsManager } from 'aws-sdk';
const secretsManager = new SecretsManager();
async function getSecret(secretName: string) {
const result = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
return JSON.parse(result.SecretString);
}
Secret Rotation#
Rotate secrets regularly:
// Implement secret rotation
async function rotateSecrets() {
// Generate new secret
const newSecret = generateSecureRandom();
// Update in secrets manager
await updateSecret('API_KEY', newSecret);
// Update in application (with grace period for old secret)
// Allow both old and new secrets for 24 hours
// After grace period, invalidate old secret
await invalidateSecret('API_KEY_OLD');
}
3. Authentication Hardening#
Secure Password Requirements#
// Enforce strong password requirements
function validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (password.length < 12) {
errors.push('Password must be at least 12 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain number');
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('Password must contain special character');
}
return {
valid: errors.length === 0,
errors
};
}
Multi-Factor Authentication (MFA)#
// Enable MFA with Supabase
async function enableMFA(userId: string) {
const supabase = createClient();
// Start MFA enrollment
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp'
});
if (error) throw error;
// Return QR code for user to scan
return data.totp.qr_code;
}
// Verify MFA code
async function verifyMFA(userId: string, code: string) {
const supabase = createClient();
const { data, error } = await supabase.auth.mfa.verify({
factorId: 'factor-id',
code
});
if (error) throw error;
return data;
}
Session Security#
// Implement secure session handling
async function createSecureSession(userId: string) {
const sessionToken = generateSecureRandom();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Store session in database
await supabase.from('sessions').insert({
user_id: userId,
token: hashToken(sessionToken),
expires_at: expiresAt,
ip_address: getClientIP(),
user_agent: getUserAgent()
});
// Set secure cookie
cookies().set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60
});
}
// Validate session
async function validateSession(token: string) {
const { data: session } = await supabase
.from('sessions')
.select('*')
.eq('token', hashToken(token))
.single();
if (!session || new Date(session.expires_at) < new Date()) {
return null;
}
return session;
}
4. API Security#
Rate Limiting#
// Implement rate limiting middleware
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN
});
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 h')
});
// Use in API route
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response('Rate limit exceeded', { status: 429 });
}
// Process request
}
Input Validation#
// Validate all user input
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
tags: z.array(z.string()).max(10)
});
export async function POST(request: Request) {
const body = await request.json();
// Validate input
const result = createPostSchema.safeParse(body);
if (!result.success) {
return new Response(
JSON.stringify({ errors: result.error.flatten() }),
{ status: 400 }
);
}
// Process validated data
const { title, content, tags } = result.data;
}
CORS Configuration#
// next.config.js
export default {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: process.env.ALLOWED_ORIGINS || 'https://yourdomain.com'
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE'
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization'
}
]
}
];
}
};
5. Security Headers#
// next.config.js
export default {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
},
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'geolocation=(), microphone=(), camera=()'
}
]
}
];
}
};
6. File Upload Security#
// Secure file upload handler
import { writeFile } from 'fs/promises';
import { extname } from 'path';
import crypto from 'crypto';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File;
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return new Response('Invalid file type', { status: 400 });
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return new Response('File too large', { status: 400 });
}
// Generate secure filename
const filename = `${crypto.randomBytes(16).toString('hex')}${extname(file.name)}`;
// Upload to Supabase Storage
const { data, error } = await supabase.storage
.from('uploads')
.upload(filename, file, {
cacheControl: '3600',
upsert: false
});
if (error) {
return new Response('Upload failed', { status: 500 });
}
return new Response(JSON.stringify({ url: data.path }));
}
7. Logging and Monitoring#
// Implement security logging
async function logSecurityEvent(event: {
type: string;
userId?: string;
ipAddress: string;
userAgent: string;
details: Record<string, any>;
}) {
await supabase.from('security_logs').insert({
event_type: event.type,
user_id: event.userId,
ip_address: event.ipAddress,
user_agent: event.userAgent,
details: event.details,
created_at: new Date()
});
}
// Log failed login attempts
async function handleFailedLogin(email: string, ipAddress: string) {
await logSecurityEvent({
type: 'failed_login',
ipAddress,
userAgent: getUserAgent(),
details: { email }
});
// Check for brute force
const { data: attempts } = await supabase
.from('security_logs')
.select('*')
.eq('event_type', 'failed_login')
.eq('ip_address', ipAddress)
.gte('created_at', new Date(Date.now() - 15 * 60 * 1000)); // Last 15 minutes
if (attempts.length > 5) {
// Block IP temporarily
await blockIP(ipAddress);
}
}
8. Security Checklist#
- ✅ Enable RLS on all tables
- ✅ Create appropriate RLS policies for each table
- ✅ Never expose service role key to client
- ✅ Store secrets in environment variables
- ✅ Use HTTPS in production
- ✅ Implement rate limiting on API endpoints
- ✅ Validate all user input
- ✅ Set security headers (CSP, X-Frame-Options, etc.)
- ✅ Implement secure password requirements
- ✅ Enable MFA for user accounts
- ✅ Use secure session handling
- ✅ Implement logging and monitoring
- ✅ Regular security audits
- ✅ Keep dependencies updated
- ✅ Have an incident response plan
Related Articles#
- Building SaaS with Next.js and Supabase
- Supabase Authentication & Authorization
- Database Optimization and Scaling
- Handle Supabase Auth Errors in Middleware
Conclusion#
Security is a continuous process, not a one-time task. Implement these best practices, stay updated on security vulnerabilities, and regularly audit your application. Remember: the cost of preventing a security breach is far less than the cost of dealing with one.
Your users trust you with their data. Protect that trust by building security into every layer of your application.