Security Review — SvelteKit SPA + Supabase
This skill applies to a SvelteKit SPA (ssr = false) using Supabase Auth, Postgres
with Row Level Security, and $env/dynamic/public for environment variables. All
application code runs in the browser — there is no server middleware, no API routes,
and no server-side rendering.
The trust boundary
The browser is untrusted. Supabase (Postgres RLS + Auth) is the only enforcement point. Every rule below follows from this.
UNTRUSTED TRUSTED
───────────────────────────────── ──────────────────────────
Browser (SvelteKit SPA) Supabase (Auth + Postgres)
├─ authState ($state) ├─ JWT verification
├─ Zod validation (UX only) ├─ RLS policies (auth.uid())
├─ Route guards (UX only) ├─ DB constraints + CHECK
└─ UI gating └─ Security definer RPCs
Client-side checks (auth guards, Zod schemas, route gating) improve UX but provide zero security. Assume an attacker can bypass every client-side check.
1. Row Level Security (RLS)
RLS is the authorization layer. Every table with user data MUST have RLS enabled.
Checklist for every new table
-
ALTER TABLE ... ENABLE ROW LEVEL SECURITY; - SELECT policy:
using (auth.uid() = user_id_column) - INSERT policy:
with check (auth.uid() = user_id_column) - UPDATE policy:
using (auth.uid() = ...) with check (auth.uid() = ...) - DELETE policy:
using (auth.uid() = user_id_column) - No policy grants access to
anonrole unless the data is intentionally public - Column defaults cannot be abused (e.g.,
role text default 'admin'is dangerous) - UPDATE policies restrict which columns can be changed when needed (use
with checkconstraints)
Common mistakes
-- BAD: allows any authenticated user to read all rows
create policy "select_all" on items for select to authenticated using (true);
-- GOOD: scoped to the row owner
create policy "select_own" on items for select to authenticated
using (auth.uid() = owner_id);
-- BAD: user can set arbitrary owner_id on insert
create policy "insert_any" on items for insert to authenticated
with check (true);
-- GOOD: forces owner_id to match the authenticated user
create policy "insert_own" on items for insert to authenticated
with check (auth.uid() = owner_id);
- Never use
using (true)unless the table is genuinely public. - Test RLS by running queries as a different user to verify row isolation.
Security definer functions
RPC functions with security definer bypass RLS. Lock them down:
create or replace function my_rpc()
returns void
language plpgsql
security definer
set search_path = public -- prevent schema injection
as $$
begin
-- ALWAYS assert the caller's identity inside the body
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
-- ... scoped operation using auth.uid() ...
end;
$$;
-- restrict to authenticated users only
revoke all on function my_rpc() from public;
grant execute on function my_rpc() to authenticated;
Migration safety
When writing SQL migrations:
- Never
ALTER TABLE ... DISABLE ROW LEVEL SECURITYunless immediately re-enabling. - Never
DROP POLICYwithout creating a replacement in the same migration. - Audit every
security definerfunction change for privilege escalation. - Review
GRANTstatements — never grantanonaccess to sensitive tables.
2. Safe Supabase query patterns
Since all queries run from the browser, treat query construction as a security surface.
// BAD: selecting all columns exposes data you may not intend to show
const { data } = await supabase.from('profiles').select('*');
// GOOD: explicit column selection minimizes exposure
const { data } = await supabase.from('profiles').select('id, display_name');
- Always select only the columns you need.
- Even with RLS, over-selecting can expose fields the UI shouldn't render (e.g., internal flags).
- Never pass user input directly into
.or()or.filter()without sanitization — build filters from known safe values.
// BAD: user controls the filter string
const { data } = await supabase.from('items').or(userInput);
// GOOD: construct filters from validated enum values
const validStatuses = ['active', 'archived'] as const;
if (validStatuses.includes(status)) {
const { data } = await supabase.from('items').eq('status', status);
}
- Use
.maybeSingle()for queries that should return 0 or 1 rows — it throws on multiple matches, preventing logic errors. - Always check
errorbefore usingdata.
3. Environment variables
This project uses $env/dynamic/public — only variables prefixed with PUBLIC_ are included.
- Only
PUBLIC_SUPABASE_URLandPUBLIC_SUPABASE_PUBLISHABLE_KEY(anon key) belong in client code. - NEVER expose
SUPABASE_SERVICE_ROLE_KEYor any secret in aPUBLIC_variable. - The anon key is safe to expose — it can only do what RLS allows.
- If a feature requires elevated privileges, use a Supabase Edge Function or
security definerRPC. - Keep
.envin.gitignore. Commit.env.examplewith placeholder values only. - Audit: ensure
.env.exampledoes not contain real credentials.
4. Cross-site scripting (XSS)
Svelte auto-escapes all template expressions by default, which prevents most XSS.
The one danger: {@html}
<!-- DANGEROUS: renders raw HTML, enabling XSS -->
{@html userProvidedContent}
<!-- SAFE: Svelte escapes this automatically -->
{userProvidedContent}
- NEVER use
{@html}with user-provided or database-sourced content. - If rich text rendering is required, sanitize with DOMPurify before
{@html}. - Audit every
{@html}usage during reviews.
Other XSS vectors to watch
window.location/window.openwith user input — always validate URLs.element.innerHTMLin imperative code — usetextContentinstead.- Dynamic
<a href={userValue}>— validate it starts with/orhttps://from a trusted domain. Ajavascript:URL is XSS. - Third-party scripts in
<svelte:head>— only include trusted sources with integrity hashes when possible.
<!-- BAD: user-controlled href can be javascript:alert(1) -->
<a href={userUrl}>Click here</a>
<!-- GOOD: validate before rendering -->
{#if userUrl.startsWith('/') || userUrl.startsWith('https://trusted.com')}
<a href={userUrl}>Click here</a>
{/if}
5. Input validation — defense in depth
Client-side Zod validation is for UX (instant feedback). It is NOT a security boundary.
| Layer | Purpose | Enforced by |
|---|---|---|
| Zod schema | Instant field feedback | Browser (bypassable) |
| RLS policy | Row-level authorization | Postgres |
| DB constraints | Data integrity (NOT NULL, CHECK, FK) | Postgres |
| Column types | Type safety | Postgres |
- Always pair Zod schemas with matching DB constraints for critical fields.
- Assume any value from the browser can be forged.
- For sensitive operations, validate inside a
security definerRPC rather than trusting client input. - Apply string length limits in both Zod and DB (
varchar(255),CHECK (char_length(name) <= 255)).
6. Authentication and session management
getUser() vs getSession() — critical distinction
// BAD for authorization: reads from local storage, can be tampered with
const { data } = await supabase.auth.getSession();
// GOOD for authorization: makes a server call to verify the JWT
const { data } = await supabase.auth.getUser();
- Use
getUser()whenever the result drives data access or authorization logic (e.g., in repositories/services). getSession()is acceptable ONLY for:- Initial UI hydration (followed by
onAuthStateChangewhich corrects it). - Checking if any session exists before a server-verified operation (e.g., password recovery flow).
- Initial UI hydration (followed by
- The Supabase client uses
getSession()internally for token refresh — that is its intended use.
Session rules
- Let Supabase handle token persistence. NEVER manually store JWTs in
localStorageor cookies. authStatein$stateis a UI convenience, not a security boundary — RLS enforces access.- Re-authenticate before destructive actions (password change, email change, account deletion) by requiring the current password.
- Use
sessionStorage(notlocalStorage) for short-lived flags likePASSWORD_RECOVERY— it clears when the tab closes. - Idempotent listener registration: use a
listenerRegisteredguard to prevent duplicateonAuthStateChangesubscriptions in dev/HMR.
7. Open redirect prevention
The next= query parameter preserves navigation intent through auth redirects. It is
currently read with page.url.searchParams.get('next') and passed directly to goto().
This is an open redirect vulnerability if unvalidated.
Required: validate every redirect target
// src/lib/utils/redirect.ts
// Validates that a redirect target is a safe relative path within the app.
// Rejects absolute URLs, protocol-relative URLs, and encoded bypass attempts.
export function getSafeRedirect(next: string | null, fallback = '/app'): string {
if (!next) return fallback;
// Decode to catch %2F%2F and similar bypass attempts
let decoded: string;
try {
decoded = decodeURIComponent(next);
} catch {
return fallback;
}
// Must be a relative path, not an absolute or protocol-relative URL
if (!decoded.startsWith('/') || decoded.startsWith('//')) return fallback;
// Block javascript: and data: schemes that could be encoded
const lower = decoded.toLowerCase();
if (lower.includes('javascript:') || lower.includes('data:')) return fallback;
return next;
}
Apply in every location that reads next:
// In login/signup pages:
void goto(getSafeRedirect(nextParam));
// NOT:
void goto(nextParam ?? '/app');
8. CSRF and request forgery
CSRF is largely mitigated in this SPA because:
- The Supabase JS client sends the auth token as a
Bearerheader, not a cookie. Authorization: Bearer <jwt>headers are not attached automatically by the browser to cross-origin requests.- There are no server-side form endpoints.
However, if you add external API calls or Supabase Edge Functions:
- Always send credentials via
Authorizationheader, never cookies. - Set
SameSite=Stricton any cookies if you introduce them. - Validate
OriginorRefererheaders in Edge Functions that accept mutations.
9. Rate limiting and brute force
Supabase applies rate limiting on auth endpoints. The app must handle rate-limit errors gracefully:
// These error codes indicate rate limiting — show a friendly message
const RATE_LIMIT_CODES = ['over_request_rate_limit', 'over_email_send_rate_limit'];
- The error mapping in
src/lib/auth/errors.tsalready handles these codes. - For email operations (verification resend, password reset): show a cooldown timer in the UI after sending, even before the server rate-limits.
- Consider client-side debouncing on login submit to prevent accidental rapid retries.
10. Sensitive data handling
Never log or expose
- Passwords (even in error messages or console logs).
- JWT tokens / access tokens.
- Service role keys.
- Full raw Supabase error objects in production — they may contain query details.
Reactive state
- Do not store secrets (API keys, tokens) in
$state— browser devtools can inspect it. authStatestores user metadata (uid, email, displayName) which is acceptable.
Error messages
- Map Supabase auth errors to generic text using
getAuthErrorMessage(). - Never expose raw Supabase error
.messageto the UI unless it has been checked against the known error map first. - The error mapping in
src/lib/auth/errors.tsis the canonical pattern — extend it for new error codes rather than showing raw messages.
11. Account enumeration prevention
Supabase prevents account enumeration by default during signup. This project handles it:
if (data.user && Array.isArray(data.user.identities) && data.user.identities.length === 0) {
throw { code: 'user_already_exists' };
}
- Keep this check — without it the signup silently succeeds without creating an account.
- For password reset, always show: "If an account exists, a reset email has been sent."
- For login failures, use a single generic message ("Invalid email or password") — never indicate whether the email exists.
12. Supabase storage security
If using Supabase Storage for file uploads:
- Use private buckets by default; public only for intentionally public assets.
- Add RLS on
storage.objectsscoped toauth.uid(). - Validate file type and size on client AND via Supabase storage policies.
- Use signed URLs with short expiration for private file access.
- Never trust the file extension — validate MIME type server-side.
- Set max file size limits in both the client upload logic and the bucket configuration.
13. Dependency security
- Run
npm auditperiodically. Fix critical and high vulnerabilities before deploying. - Pin dependencies in
package-lock.json— commit the lockfile. - Audit new dependencies before adding them: check maintenance, download count, and known vulnerabilities.
- Prefer well-known packages with active maintenance over niche alternatives.
Security review checklist
Use this when reviewing a feature, PR, or before deployment:
Data layer
- Every new table has RLS enabled with scoped policies
- No
using (true)policies unless data is genuinely public - INSERT policies enforce
auth.uid() = owner_column - UPDATE policies restrict which columns can change if needed
- Zod schemas have matching DB constraints for critical fields
- Queries use explicit column selection, not
select('*') - Security definer RPCs are restricted to
authenticatedrole - Security definer RPCs assert
auth.uid()inside the function body - Migrations never drop RLS or policies without immediate replacement
Auth and sessions
-
getUser()is used for authorization decisions, notgetSession() - Destructive actions require current password re-authentication
- No manual JWT storage in
localStorageor cookies - Rate-limit errors are handled with user-friendly messages
Client-side
- No
{@html}with unsanitized user content - No
PUBLIC_env vars contain secrets -
next=redirect values are validated withgetSafeRedirect() - Dynamic
hrefattributes are validated (nojavascript:ordata:URLs) - Error messages shown to users do not expose internal details
- No passwords, tokens, or PII in console logs
- File uploads validate type and size on client and server
Infrastructure
-
.envis in.gitignore -
.env.examplecontains only placeholder values -
npm auditshows no critical or high vulnerabilities -
package-lock.jsonis committed
Source: wesselgrift/sveltekit-spa — distributed by TomeVault.