Next.js and Supabase are one of the most popular stacks for building modern web applications. But the speed of development often means security gets overlooked. Here's a comprehensive checklist to lock down your app.
1. Never Expose Your Supabase Service Role Key
The SUPABASE_SERVICE_ROLE_KEY bypasses all Row Level Security (RLS) policies. If it leaks to the client, attackers have full database access.
Common mistakes:
- Using
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY(theNEXT_PUBLIC_prefix exposes it to the browser) - Importing the service role client in a Client Component
- Hardcoding the key in API routes that get bundled client-side
Fix: Only use the service role key in server-side code (API routes, Server Components, Edge Functions). Use the anon key for client-side operations and rely on RLS to control access.
2. Enable Row Level Security (RLS) on Every Table
Supabase disables RLS by default on new tables. Without RLS, any authenticated user can read and modify any row.
-- Enable RLS
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
-- Users can only read their own data
CREATE POLICY "Users read own data" ON your_table
FOR SELECT USING (auth.uid() = user_id);
-- Users can only insert their own data
CREATE POLICY "Users insert own data" ON your_table
FOR INSERT WITH CHECK (auth.uid() = user_id);
Complex RLS Policies with Joins
Basic auth.uid() = user_id policies work for simple cases, but real applications need more nuanced access control. Here are patterns for common multi-table scenarios.
Team-based access -- allow users to access data belonging to their team:
-- Users can read projects belonging to their team
CREATE POLICY "Team members read projects" ON projects
FOR SELECT USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id = projects.team_id
AND team_members.user_id = auth.uid()
)
);
Role-based access -- different permissions for admins vs. members:
-- Only team admins can delete projects
CREATE POLICY "Admins delete projects" ON projects
FOR DELETE USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id = projects.team_id
AND team_members.user_id = auth.uid()
AND team_members.role = 'admin'
)
);
-- Members can update projects but not delete them
CREATE POLICY "Members update projects" ON projects
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id = projects.team_id
AND team_members.user_id = auth.uid()
AND team_members.role IN ('admin', 'member')
)
);
Shared resources with explicit grants -- for invite-based sharing:
-- Users can read documents shared with them
CREATE POLICY "Shared document access" ON documents
FOR SELECT USING (
user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM document_shares
WHERE document_shares.document_id = documents.id
AND document_shares.shared_with = auth.uid()
AND document_shares.revoked_at IS NULL
)
);
Important performance tip: Always create indexes on the columns used in your RLS policy joins. Without indexes, every row access triggers a sequential scan on the joined table, which can devastate performance at scale.
CREATE INDEX idx_team_members_user_team
ON team_members (user_id, team_id);
CREATE INDEX idx_document_shares_doc_user
ON document_shares (document_id, shared_with)
WHERE revoked_at IS NULL;
CheckVibe's Supabase scanner automatically detects tables with RLS disabled and flags exposed service role keys.
3. Set Security Headers
Next.js doesn't set security headers by default. Add them in next.config.ts:
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self' 'unsafe-inline'" },
],
}];
}
4. Validate and Sanitize All User Input
Never trust user input — even when using Supabase's auto-generated API.
- Use Zod or similar validation on all form inputs and API route parameters
- Sanitize HTML content before rendering (prevent stored XSS)
- Parameterize all database queries (Supabase does this by default, but watch for raw SQL via
rpc())
5. Protect API Routes
Next.js API routes are publicly accessible by default. Add authentication checks:
import { createClient } from '@/lib/supabase/server';
export async function POST(req: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// ... handle the request
}
6. Use HTTPS Everywhere
HTTPS is the foundation of transport security. Without it, every request between your users and your server -- including authentication tokens, form submissions, and API calls -- can be intercepted by anyone on the same network.
Ensure your domain has a valid SSL certificate. If you deploy on Vercel, this is automatic. For self-hosted deployments, use Let's Encrypt with automatic renewal via certbot or a reverse proxy like Caddy that handles TLS out of the box.
Add HSTS headers to prevent downgrade attacks. The Strict-Transport-Security header tells browsers to always use HTTPS for your domain, even if a user types http://. Without it, an attacker on a public Wi-Fi network can intercept the initial HTTP request before the redirect happens. The preload directive goes further -- it submits your domain to browser vendors' built-in HSTS lists so that even the very first visit uses HTTPS:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Check for mixed content. A single HTTP image or script on an HTTPS page creates a vulnerability. Browsers block mixed active content (scripts, iframes) but may still load mixed passive content (images) with a console warning. Audit your site with browser DevTools or a scanner like CheckVibe to catch any HTTP resources loaded on HTTPS pages.
Supabase connections are HTTPS by default, but if you use custom database connections or direct Postgres access, verify that sslmode=require or sslmode=verify-full is set in your connection string.
7. Secure Authentication Flows
Authentication is the gatekeeper to your entire application. A weak auth implementation can undermine every other security measure you have in place.
Use Supabase's built-in auth with PKCE flow for SPAs. The PKCE (Proof Key for Code Exchange) flow prevents authorization code interception attacks. Supabase Auth supports this natively -- when you initialize @supabase/ssr, it uses PKCE automatically for browser-based flows.
Set SameSite=Lax or Strict on auth cookies. The SameSite attribute prevents browsers from sending cookies on cross-site requests, which is your primary defense against CSRF attacks. Lax is the recommended default because it allows cookies on top-level navigations (so links from emails still work) while blocking cross-site POST requests. Use Strict only if your app never needs to be accessed via external links.
Implement CSRF protection on state-changing endpoints. Even with SameSite cookies, add explicit CSRF validation on all POST, PUT, and DELETE routes. The simplest approach is to validate the Origin header against an allowlist:
export async function POST(req: Request) {
const origin = req.headers.get('origin');
const allowedOrigins = [
'https://yourdomain.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
if (!origin || !allowedOrigins.includes(origin)) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// ... proceed with authenticated request
}
Rate-limit login attempts to prevent brute force. Supabase has built-in rate limiting on the auth endpoints, but add your own layer for custom auth routes. A sliding window rate limiter (e.g., 5 attempts per minute per IP) is effective and simple to implement.
Never expose user enumeration through error messages. Return a generic "Invalid credentials" message for both "user not found" and "wrong password" cases. Attackers use targeted error messages to confirm which email addresses have accounts, enabling more focused attacks.
Add middleware.ts for Auth Protection
Next.js middleware runs before every request and is the best place to enforce authentication across your app. Without it, you rely on each page or API route to check auth individually -- which is error-prone and easy to forget.
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// Refresh the session -- this is critical for SSR
const { data: { user } } = await supabase.auth.getUser();
// Protect dashboard routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
Why getUser() instead of getSession()? The getUser() method validates the JWT against Supabase's auth server, while getSession() only reads the local token. An expired or tampered token passes getSession() but fails getUser(). Always use getUser() for security-critical checks.
8. Audit Third-Party Dependencies
Your application is only as secure as its weakest dependency. A single vulnerable package in your node_modules tree can expose your users to remote code execution, data theft, or supply chain attacks.
Run npm audit regularly and treat high/critical findings as blockers. Integrate npm audit into your CI/CD pipeline so that builds fail on known vulnerabilities:
# In your CI pipeline (GitHub Actions, Vercel build, etc.)
npm audit --audit-level=high
# Auto-fix what can be fixed
npm audit fix
# For a comprehensive report with fix suggestions
npx audit-ci --high
Use tools like Snyk or Socket for CVE monitoring. npm audit only catches known vulnerabilities in your direct and transitive dependencies. Tools like Snyk add continuous monitoring, alert you when new CVEs are published for packages you use, and can automatically open PRs with fixes.
Review new dependencies before installing. Before adding a package, check:
- Download counts -- popular packages get more eyes on security issues
- Last publish date -- abandoned packages do not get security patches
- Maintainer count -- single-maintainer packages are a supply chain risk
- Known issues -- search the package name + "vulnerability" or "malware"
- Package size and dependencies -- large dependency trees increase attack surface
Lock your dependency versions. Use package-lock.json (npm) or pnpm-lock.yaml (pnpm) and commit it to your repository. This ensures every build uses the exact same versions, preventing supply chain attacks where a compromised version is published as a patch update.
Consider using --ignore-scripts for untrusted packages. The postinstall script in npm packages runs arbitrary code on your machine during npm install. For packages you do not fully trust, install with npm install --ignore-scripts and run scripts manually after reviewing them.
9. Protect Against Common Attacks
Every web application faces a standard set of attacks. Here is how to defend against each one in a Next.js + Supabase stack.
CORS (Cross-Origin Resource Sharing)
Restrict Access-Control-Allow-Origin to your domain -- never use * with credentials. Misconfigured CORS is one of the most common findings in security scans. If your API returns Access-Control-Allow-Origin: *, any website can make authenticated requests to your API (when combined with other misconfigurations).
// next.config.ts -- restrict CORS in API routes
export async function GET(req: Request) {
const origin = req.headers.get('origin');
const allowed = ['https://yourdomain.com'];
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
if (origin && allowed.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
}
return Response.json({ data: '...' }, { headers });
}
For Supabase Edge Functions, set the ALLOWED_ORIGIN environment variable rather than hardcoding * in CORS headers.
CSRF (Cross-Site Request Forgery)
Validate the Origin header on all POST/PUT/DELETE requests. As shown in section 7, compare the request's Origin against an allowlist. This catches the vast majority of CSRF attacks because browsers always send the Origin header on cross-origin requests.
Open Redirects
Validate redirect URLs against an allowlist. A common pattern in auth flows is to redirect users after login via a ?redirect= parameter. If you do not validate this parameter, attackers can craft URLs like yourdomain.com/login?redirect=https://evil.com for phishing:
function getSafeRedirect(url: string | null): string {
if (!url) return '/dashboard';
try {
const parsed = new URL(url, 'https://yourdomain.com');
// Only allow same-origin redirects
if (parsed.origin !== 'https://yourdomain.com') {
return '/dashboard';
}
return parsed.pathname + parsed.search;
} catch {
return '/dashboard';
}
}
File Uploads
Validate file types on both client and server, limit file sizes, and never serve uploaded files from your main domain. Use Supabase Storage with its built-in access policies, and consider scanning uploads with a malware detection service for sensitive applications.
SSRF (Server-Side Request Forgery)
If your application makes server-side HTTP requests based on user input (e.g., fetching a URL preview, validating a webhook endpoint, or loading a favicon), validate that the target URL does not resolve to a private IP address. Attackers use SSRF to access internal services, cloud metadata endpoints (like 169.254.169.254), and other resources behind your firewall:
import { isPrivateIP } from '@/lib/url-validation';
async function fetchExternalUrl(url: string) {
const parsed = new URL(url);
// Block private IPs, localhost, and metadata endpoints
const resolved = await dns.resolve4(parsed.hostname);
for (const ip of resolved) {
if (isPrivateIP(ip)) {
throw new Error('URL resolves to a private address');
}
}
return fetch(url, { signal: AbortSignal.timeout(10000) });
}
10. Monitor Continuously
Security is not a one-time check. New vulnerabilities are discovered daily, dependencies get compromised, and developers accidentally introduce security regressions in routine code changes. Continuous monitoring is what turns a secure application into one that stays secure.
Scan on every deploy. Integrate security scanning into your CI/CD pipeline so that every pull request and deployment is checked automatically. This catches issues before they reach production rather than after. CheckVibe supports webhook-triggered scans that run your full 100+ check suite on every Vercel or Netlify deploy.
Set up alerts for new critical findings. Do not rely on remembering to check a dashboard. Configure alerts that notify you immediately via email, Slack, or Discord when a new critical or high-severity issue is detected. CheckVibe's monitoring feature supports score-drop alerts (e.g., "notify me if my security score drops below 80") and new-critical alerts (e.g., "notify me whenever a new critical finding appears").
Review your security posture weekly. Even with automated scanning, schedule a brief weekly review to look at trends. Is your security score improving or declining? Are there recurring issues that point to a systemic problem? Are new third-party integrations introducing risk?
Track your security score over time. A score that slowly declines over weeks indicates a pattern -- maybe you are adding features without security review, or dependencies are falling behind on patches. Use trend data to make the case for dedicated security work during sprint planning.
Test your monitoring with intentional regressions. Introduce a known issue in a staging environment and verify that your monitoring catches it. If it does not, your monitoring has gaps.
Secure Your Supabase Edge Functions
If you use Supabase Edge Functions as your backend, they need their own security hardening. Edge Functions run in Deno and are publicly accessible by default.
Validate the caller. Do not rely solely on --no-verify-jwt convenience. For sensitive operations, verify the auth token manually:
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
Deno.serve(async (req) => {
// Validate origin
const origin = req.headers.get('origin');
if (origin !== Deno.env.get('ALLOWED_ORIGIN')) {
return new Response('Forbidden', { status: 403 });
}
// Validate auth token
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
);
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response('Unauthorized', { status: 401 });
}
// Proceed with authenticated logic
});
Use a shared secret for server-to-server calls. If your Next.js API routes call Edge Functions, authenticate them with a shared secret (stored as a Supabase secret via supabase secrets set) rather than passing user tokens:
// In your Edge Function
const scannerKey = req.headers.get('x-scanner-key');
if (scannerKey !== Deno.env.get('SCANNER_SECRET_KEY')) {
return new Response('Forbidden', { status: 403 });
}
Set timeouts on all external requests. Edge Functions have a 60-second wall clock limit. Any fetch call to an external API should have an explicit timeout to prevent a slow third-party service from hanging your function:
const response = await fetch(externalUrl, {
signal: AbortSignal.timeout(15000), // 15 second timeout
});
Automated Scanning with CheckVibe
CheckVibe's Supabase-specific scanner checks for:
- Exposed service role keys in client-side code
- Tables with RLS disabled
- Missing security headers
- Leaked API keys from 100+ providers
- CORS misconfigurations
- And 31 more vulnerability types
Scan your Next.js + Supabase app for free -- 100+ checks in under 60 seconds.
FAQ
Is Supabase secure for production?
Yes -- Supabase is suitable for production workloads, but security depends on how you configure it. Supabase provides the building blocks (RLS, auth, encrypted connections), but you must enable and configure them correctly. The most common mistakes are leaving RLS disabled on tables, exposing the service role key to the client, and using overly permissive RLS policies. With proper configuration -- RLS on every table, the service role key restricted to server-side code, and auth flows using PKCE -- Supabase is as secure as any managed database platform. CheckVibe's Supabase security checklist covers every configuration step.
Do I need middleware.ts for auth?
You do not strictly need it, but you should use it. Without middleware, you must add authentication checks individually to every page and API route. This is tedious and error-prone -- it only takes one forgotten check to create an unauthorized access vulnerability. Middleware runs before every matching request and provides a centralized place to enforce authentication, refresh sessions, and redirect unauthenticated users. The middleware pattern shown in section 7 above is the recommended approach from the Supabase team. For a deeper dive into Next.js auth patterns, see our Next.js security best practices guide.
How do I test RLS policies?
Test RLS policies by making requests as different users and verifying the results match your expectations. Here are three approaches:
1. Use the Supabase SQL Editor with role switching:
-- Test as a specific user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';
-- This should return only the user's own rows
SELECT * FROM your_table;
-- Reset
RESET role;
RESET request.jwt.claims;
2. Write automated tests that create two users, insert data as user A, and verify that user B cannot read, update, or delete it. Use the Supabase client initialized with each user's JWT.
3. Use CheckVibe's automated scanner which tests common RLS misconfigurations including missing policies, overly permissive SELECT policies, and tables with RLS disabled entirely.
The key principle is to test the negative case: do not just verify that the correct user can access their data -- verify that other users cannot.
What's the biggest security mistake in Next.js + Supabase?
The single most dangerous mistake is exposing the SUPABASE_SERVICE_ROLE_KEY to the client. This key bypasses all RLS policies and gives whoever holds it full read/write access to every table in your database. It happens when developers use the NEXT_PUBLIC_ prefix on the service role key, import a service-role client in a Client Component, or accidentally include it in a client-side bundle. Unlike other security issues that require chaining multiple vulnerabilities, a leaked service role key is an immediate, total compromise of your database. For a complete deployment security walkthrough, check our Vercel deployment security checklist.