SvelteKit + Supabase Security
The complete security checklist for vibe coders using this popular stack
SvelteKit + Supabase is powerful but has security traps AI tools fall into. The biggest: using getSession() instead of getUser() on the server, missing RLS policies, and exposed service role keys. OWASP A01:2021.
Stack Security Overview
Known vulnerabilities in this stack
Keep your dependencies updated. These CVEs affected SvelteKit and Svelte:
1. The getSession() vs getUser() trap
This is the #1 mistake in SvelteKit + Supabase vibe coded apps. AI tools like Cursor and Claude Code generate this constantly because tutorials optimize for speed, not security.
// +page.server.ts
export async function load({ locals }) {
const { data: { session } } =
await locals.supabase.auth.getSession()
if (!session) {
throw redirect(303, '/login')
}
// DANGER: session from cookies - can be spoofed!
const userId = session.user.id
// ... use userId for database queries
}// +page.server.ts
export async function load({ locals }) {
const { data: { user }, error } =
await locals.supabase.auth.getUser()
if (error || !user) {
throw redirect(303, '/login')
}
// SAFE: validated against Supabase Auth server
const userId = user.id
// ... use userId for database queries
}Why AI gets this wrong: getSession() is faster because it reads from cookies without a network call. But cookies can be modified by attackers. getUser() validates the token with Supabase Auth server.
2. Missing RLS policies
Tables without Row Level Security are readable by anyone with your anon key (which is in your client bundle). This is an IDOR vulnerability waiting to happen.
-- Table created without RLS
CREATE TABLE user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
full_name TEXT,
avatar_url TEXT
);
-- Anyone can read/write ALL profiles!-- Enable RLS and add policies
ALTER TABLE user_profiles
ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile"
ON user_profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON user_profiles FOR UPDATE
USING (auth.uid() = id);RLS is defense in depth. Even if your server code has a bug, attackers can only access their own data. Read the Supabase RLS documentation for more patterns.
3. Service role key exposure
The service role key bypasses ALL RLS. Exposing it gives attackers full database access. This is often a hardcoded secrets issue.
# .env (CATASTROPHIC!)
PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...
# This gets bundled into client code!# .env
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...
# No PUBLIC_ prefix = server-only
// +page.server.ts (only place to use it)
import { SUPABASE_SERVICE_ROLE_KEY }
from '$env/static/private'If you've exposed your service role key, rotate it immediately in the Supabase dashboard. Check your Git history for accidental commits.
4. Proper hooks.server.ts setup
Your hooks.server.ts is the foundation of server-side auth. Without it, auth state is unreliable. Here's the complete secure setup from the Supabase SSR guide:
// src/hooks.server.ts
import { createServerClient } from '@supabase/ssr'
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY }
from '$env/static/public'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
get: (key) => event.cookies.get(key),
set: (key, value, options) => {
event.cookies.set(key, value, { ...options, path: '/' })
},
remove: (key, options) => {
event.cookies.delete(key, { ...options, path: '/' })
},
},
}
)
// IMPORTANT: Use getUser() not getSession()
event.locals.getUser = async () => {
const { data: { user }, error } =
await event.locals.supabase.auth.getUser()
if (error) return null
return user
}
return resolve(event)
}5. Don't rely on layout-only auth checks
Auth checks in +layout.server.ts don't reliably protect child routes. SvelteKit can skip layouts in certain scenarios.
// +layout.server.ts
export async function load({ locals }) {
const user = await locals.getUser()
if (!user) throw redirect(303, '/login')
return { user }
}
// Child +page.server.ts trusts parent
export async function load({ parent }) {
const { user } = await parent()
// Layout might be bypassed!
}// +page.server.ts
export async function load({ locals }) {
const user = await locals.getUser()
if (!user) throw redirect(303, '/login')
// Now safe to use user.id
const { data } = await locals.supabase
.from('profiles')
.select('id, name, avatar_url')
.eq('user_id', user.id)
.single()
return { profile: data }
}6. Server-side validation in form actions
Client-side validation is UX, not security. Form actions must validate with Zod or similar, and check auth. This prevents missing auth vulnerabilities.
// +page.server.ts
export const actions = {
updateProfile: async ({ request, locals }) => {
const data = await request.formData()
const name = data.get('name') // No validation!
await locals.supabase
.from('profiles')
.update({ name })
.eq('id', locals.user.id)
}
}import { z } from 'zod'
import { fail } from '@sveltejs/kit'
const profileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional()
})
export const actions = {
updateProfile: async ({ request, locals }) => {
const user = await locals.getUser()
if (!user) return fail(401, { error: 'Unauthorized' })
const data = await request.formData()
const result = profileSchema.safeParse({
name: data.get('name'),
bio: data.get('bio')
})
if (!result.success) {
return fail(400, { errors: result.error.flatten() })
}
await locals.supabase
.from('profiles')
.update(result.data)
.eq('user_id', user.id)
}
}7. Views bypass RLS by default
PostgreSQL views run as the creator (postgres) by default, completely ignoring your RLS policies. Use security_invoker (Postgres 15+):
CREATE VIEW public_user_data AS
SELECT id, email, created_at
FROM auth.users;
-- Exposes ALL users!CREATE VIEW public_user_data
WITH (security_invoker = true) AS
SELECT id, email, created_at
FROM auth.users;
-- Respects RLS of calling user8. Don't use user_metadata for authorization
Users can modify their own user_metadata. Using it for role checks is an authorization bypass vulnerability.
CREATE POLICY "Admins can delete"
ON posts FOR DELETE
USING (
(auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'
);
-- Users can set their own role!CREATE POLICY "Admins can delete"
ON posts FOR DELETE
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE user_id = auth.uid()
)
);
-- Only server can add to admin_users9. Don't expose sensitive data from load functions
Everything returned from load() gets serialized and sent to the client. This is a sensitive data exposure risk.
export async function load({ locals }) {
const { data: user } = await locals.supabase
.from('users')
.select('*') // Returns everything!
.single()
return { user } // Sent to client!
}export async function load({ locals }) {
const { data: user } = await locals.supabase
.from('users')
.select('id, name, email, avatar_url')
.single()
return { user } // Only safe fields
}10. Ensure CSRF protection is enabled
SvelteKit has built-in CSRF protection. Make sure it's not disabled. CVE-2023-29003 was a bypass fixed in 1.15.1.
// svelte.config.js
export default {
kit: {
csrf: {
checkOrigin: false // DANGEROUS!
}
}
}// svelte.config.js
export default {
kit: {
csrf: {
checkOrigin: true // Default - keep it
}
}
}Security Checklist
Use this checklist before deploying your SvelteKit + Supabase app:
Authentication
Database
Secrets
Validation
Config
0 / 12 complete
AI Fix Prompt for SvelteKit + Supabase
Copy this prompt to scan your vibe coded project:
Review my SvelteKit + Supabase codebase for security vulnerabilities:
## 1. getSession() vs getUser() Check
Search for getSession() calls in server-side code:
- +page.server.ts, +layout.server.ts, hooks.server.ts, API routes
Pattern to find:
- auth.getSession() in any server file
- Using session.user for authorization decisions
Fix: Replace with auth.getUser() for all server-side auth.
## 2. RLS Policy Audit
For each table in supabase/migrations or schema.sql:
- Check: ALTER TABLE ... ENABLE ROW LEVEL SECURITY
- Verify SELECT/INSERT/UPDATE/DELETE policies exist
- Ensure policies use auth.uid() not user_metadata
## 3. Service Role Key Exposure
Search for service role key usage:
- PUBLIC_SUPABASE_SERVICE_ROLE anywhere
- createClient() with service role in client code
- Service role in +page.svelte or +layout.svelte
## 4. hooks.server.ts Security
Check src/hooks.server.ts for:
- Proper Supabase client with cookie handling
- getUser() helper (not getSession)
- Auth validation in handle function
## 5. Form Actions Validation
For each form action:
- Auth check with getUser() at start
- Input validation (Zod or similar)
- Proper error handling with fail()
## 6. Load Function Data Exposure
Check load functions for:
- select('*') usage (should be specific fields)
- Sensitive data in return statements
For each issue: show file, explain risk, provide fix.Frequently Asked Questions
How do I add authentication to SvelteKit with Supabase?
Set up hooks.server.ts with createServerClient from @supabase/ssr, create a getUser helper on event.locals that uses auth.getUser() (not getSession), then check user in each +page.server.ts load function. See the complete hooks.server.ts example in this guide.
What is the difference between getSession and getUser in Supabase?
getSession() reads from cookies without validation - attackers can spoof it. getUser() validates against Supabase Auth server. Always use getUser() on the server for auth decisions. getSession() is only safe client-side where you cannot trust data anyway.
Do I need RLS if I'm using server-side auth?
Yes. RLS is defense in depth. If your server code has a bug, RLS prevents data leakage. The anon key is in your client bundle - anyone can call Supabase directly. RLS ensures they only access their own data regardless of how they connect.
How do I protect routes in SvelteKit?
Check auth in every +page.server.ts load function using locals.getUser(). Don't rely only on +layout.server.ts - it can be bypassed. Use throw redirect(303, '/login') when unauthenticated. Also use RLS as a backup layer.
Is the Supabase anon key safe to expose?
Yes, the anon key is designed to be public. It only has permissions defined by your RLS policies. The service_role key is the dangerous one - it bypasses ALL RLS. Never expose service_role in client code or PUBLIC_ environment variables.