$ cat /posts/supabase-security-best-practices-protect-your-database.md
[tags]Supabase

Supabase Security Best Practices: Protect Your Database

drwxr-xr-x2026-01-265 min0 views
Supabase Security Best Practices: Protect Your Database

Securing Supabase applications protects sensitive data, prevents unauthorized access, ensures compliance with regulations, and maintains user trust through Row Level Security policies controlling data access, API key management preventing credential leaks, SQL injection prevention validating user input, authentication security implementing strong passwords and MFA, and rate limiting protecting against abuse. Unlike insecure applications vulnerable to data breaches, unauthorized modifications, credential theft, or denial-of-service attacks causing financial loss and reputation damage, properly secured applications enforce access control at database level, validate all inputs, rotate secrets regularly, monitor suspicious activity, and follow security best practices throughout development lifecycle. This comprehensive guide covers implementing Row Level Security for granular access control, securing API keys and environment variables, preventing SQL injection attacks, configuring secure authentication policies, enabling Multi-Factor Authentication, implementing rate limiting and DDoS protection, auditing database activity, encrypting sensitive data, securing file uploads, and preparing for security incidents. Security becomes critical when handling user data, processing payments, storing personal information, building multi-tenant applications, or achieving compliance with GDPR, HIPAA, or other regulations. Before proceeding, understand Row Level Security, authentication, and database queries.

Row Level Security Foundation

sqlrls_policies.sql
-- Enable RLS on all tables
alter table posts enable row level security;
alter table profiles enable row level security;
alter table comments enable row level security;

-- Policy 1: Users can only read their own profile
create policy "Users can view own profile"
  on profiles for select
  using (auth.uid() = id);

-- Policy 2: Users can update their own profile
create policy "Users can update own profile"
  on profiles for update
  using (auth.uid() = id);

-- Policy 3: Public posts are readable by everyone
create policy "Public posts are viewable"
  on posts for select
  using (published = true);

-- Policy 4: Users can only insert posts as themselves
create policy "Users can create posts"
  on posts for insert
  with check (auth.uid() = user_id);

-- Policy 5: Users can only update/delete their own posts
create policy "Users can update own posts"
  on posts for update
  using (auth.uid() = user_id);

create policy "Users can delete own posts"
  on posts for delete
  using (auth.uid() = user_id);

-- Policy 6: Prevent users from modifying others' data
create policy "Prevent unauthorized modifications"
  on comments for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Advanced: Role-based access
create policy "Admins have full access"
  on posts for all
  using (
    exists (
      select 1 from profiles
      where id = auth.uid()
      and role = 'admin'
    )
  );

-- Policy for multi-tenancy
create policy "Tenant isolation"
  on data for all
  using (
    tenant_id = (
      select tenant_id from profiles
      where id = auth.uid()
    )
  );

-- Verify RLS is enabled
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;

API Key Management

bashapi_key_security.sh
# .env.example - Template for environment variables
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_ANON_KEY=your-anon-key

# NEVER commit .env to version control!
# Add to .gitignore
.env
.env.local
.env.production

# API Key Security Practices

# 1. Use anon key for client-side (safe)
const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

# 2. NEVER expose service_role key on client
# ❌ WRONG - Service role key on frontend
const supabase = createClient(url, SERVICE_ROLE_KEY) // Dangerous!

# 3. Use service_role only on backend
// backend/server.js
import { createClient } from '@supabase/supabase-js'

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY, // Server-side only
  {
    auth: {
      autoRefreshToken: false,
      persistSession: false,
    },
  }
)

# 4. Rotate keys if compromised
# Go to: Project Settings > API > Reset keys
# Update all applications immediately

# 5. Use environment-specific keys
# .env.development
VITE_SUPABASE_URL=https://dev-project.supabase.co
VITE_SUPABASE_ANON_KEY=dev-anon-key

# .env.production
VITE_SUPABASE_URL=https://prod-project.supabase.co
VITE_SUPABASE_ANON_KEY=prod-anon-key

# 6. Validate environment variables
if (!import.meta.env.VITE_SUPABASE_URL) {
  throw new Error('Missing VITE_SUPABASE_URL')
}

if (!import.meta.env.VITE_SUPABASE_ANON_KEY) {
  throw new Error('Missing VITE_SUPABASE_ANON_KEY')
}

# 7. Use secure headers
// Next.js middleware
export function middleware(request: NextRequest) {
  const response = NextResponse.next()
  
  // Security headers
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-XSS-Protection', '1; mode=block')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  
  return response
}

SQL Injection Prevention

typescriptinput_validation.ts
// ❌ WRONG - Never build raw SQL queries
const userInput = request.body.username
const query = `SELECT * FROM users WHERE username = '${userInput}'`
// Vulnerable to: admin' OR '1'='1

// ✅ CORRECT - Use parameterized queries (Supabase does this automatically)
const { data, error } = await supabase
  .from('users')
  .select('*')
  .eq('username', userInput) // Automatically sanitized

// Input validation with Zod
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10).max(10000),
  published: z.boolean(),
})

async function createPost(data: unknown) {
  try {
    // Validate input
    const validated = postSchema.parse(data)
    
    // Safe to use
    const { data: post, error } = await supabase
      .from('posts')
      .insert(validated)
      .select()
      .single()
    
    return post
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw new Error('Invalid input data')
    }
    throw error
  }
}

// Sanitize HTML content
import DOMPurify from 'isomorphic-dompurify'

function sanitizeContent(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u'],
    ALLOWED_ATTR: [],
  })
}

// Validate file uploads
function validateFile(file: File): boolean {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
  const maxSize = 5 * 1024 * 1024 // 5MB
  
  if (!allowedTypes.includes(file.type)) {
    throw new Error('Invalid file type')
  }
  
  if (file.size > maxSize) {
    throw new Error('File too large')
  }
  
  return true
}

// Database-level constraints
-- SQL schema validation
create table posts (
  id uuid default gen_random_uuid() primary key,
  title text not null check (length(title) between 3 and 200),
  content text not null check (length(content) between 10 and 10000),
  email text check (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$'),
  age int check (age >= 18 and age <= 120),
  status text check (status in ('draft', 'published', 'archived')),
  created_at timestamp with time zone default now()
);

Authentication Security

typescriptauth_security.ts
// 1. Strong password requirements
// Configure in Supabase Dashboard > Authentication > Policies
// Minimum password length: 8 characters
// Require uppercase, lowercase, numbers, special characters

// Client-side validation
function validatePassword(password: string): boolean {
  const minLength = 8
  const hasUppercase = /[A-Z]/.test(password)
  const hasLowercase = /[a-z]/.test(password)
  const hasNumber = /[0-9]/.test(password)
  const hasSpecial = /[!@#$%^&*]/.test(password)
  
  return (
    password.length >= minLength &&
    hasUppercase &&
    hasLowercase &&
    hasNumber &&
    hasSpecial
  )
}

// 2. Email verification
const { data, error } = await supabase.auth.signUp({
  email: '[email protected]',
  password: 'SecurePassword123!',
  options: {
    emailRedirectTo: 'https://yourapp.com/verify',
  },
})

// 3. Multi-Factor Authentication (MFA)
// Enable MFA
const { data: { factors } } = await supabase.auth.mfa.enroll({
  factorType: 'totp',
})

// Verify MFA
const { data, error } = await supabase.auth.mfa.verify({
  factorId: factors[0].id,
  code: '123456',
})

// 4. Session management
// Set session timeout in Dashboard > Authentication > Settings
// JWT Expiry: 3600 seconds (1 hour)
// Refresh Token: 7 days

// Detect inactive sessions
let sessionTimeout: NodeJS.Timeout

function resetSessionTimer() {
  clearTimeout(sessionTimeout)
  sessionTimeout = setTimeout(() => {
    supabase.auth.signOut()
    alert('Session expired due to inactivity')
  }, 30 * 60 * 1000) // 30 minutes
}

window.addEventListener('mousemove', resetSessionTimer)
window.addEventListener('keypress', resetSessionTimer)

// 5. Prevent brute force attacks
// Rate limit login attempts
let loginAttempts = 0
let lockoutUntil: Date | null = null

async function login(email: string, password: string) {
  if (lockoutUntil && new Date() < lockoutUntil) {
    throw new Error('Too many attempts. Try again later.')
  }
  
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })
  
  if (error) {
    loginAttempts++
    
    if (loginAttempts >= 5) {
      lockoutUntil = new Date(Date.now() + 15 * 60 * 1000) // 15 min
      throw new Error('Account locked. Try again in 15 minutes.')
    }
    
    throw error
  }
  
  loginAttempts = 0
  return data
}

// 6. Secure password reset
const { error } = await supabase.auth.resetPasswordForEmail(
  '[email protected]',
  {
    redirectTo: 'https://yourapp.com/reset-password',
  }
)

// 7. OAuth security
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'https://yourapp.com/auth/callback',
    scopes: 'email profile', // Minimal required scopes
  },
})

Rate Limiting and DDoS Protection

typescriptrate_limiting.ts
// Edge Function rate limiting
// supabase/functions/api/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const rateLimitMap = new Map<string, { count: number; resetAt: number }>()

function checkRateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
  const now = Date.now()
  const record = rateLimitMap.get(ip)
  
  if (!record || now > record.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs })
    return true
  }
  
  if (record.count >= limit) {
    return false
  }
  
  record.count++
  return true
}

serve(async (req) => {
  const ip = req.headers.get('x-forwarded-for') || 'unknown'
  
  if (!checkRateLimit(ip, 100, 60000)) {
    return new Response(
      JSON.stringify({ error: 'Too many requests' }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': '60',
        },
      }
    )
  }
  
  // Process request
  return new Response(JSON.stringify({ success: true }))
})

// Client-side rate limiting
class RateLimiter {
  private requests: number[] = []
  
  constructor(
    private maxRequests: number,
    private windowMs: number
  ) {}
  
  async throttle<T>(fn: () => Promise<T>): Promise<T> {
    const now = Date.now()
    this.requests = this.requests.filter(time => now - time < this.windowMs)
    
    if (this.requests.length >= this.maxRequests) {
      const oldestRequest = this.requests[0]
      const waitTime = this.windowMs - (now - oldestRequest)
      await new Promise(resolve => setTimeout(resolve, waitTime))
    }
    
    this.requests.push(now)
    return fn()
  }
}

const limiter = new RateLimiter(10, 60000) // 10 requests per minute

// Usage
const data = await limiter.throttle(() =>
  supabase.from('posts').select('*')
)

// Database query rate limiting
-- Enable pg_cron for cleanup
create extension if not exists pg_cron;

-- Rate limit tracking table
create table request_logs (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users(id),
  endpoint text not null,
  created_at timestamp with time zone default now()
);

-- Function to check rate limit
create or replace function check_rate_limit(
  p_user_id uuid,
  p_endpoint text,
  p_max_requests int,
  p_window_seconds int
)
returns boolean as $$
declare
  request_count int;
begin
  select count(*)
  into request_count
  from request_logs
  where user_id = p_user_id
    and endpoint = p_endpoint
    and created_at > now() - (p_window_seconds || ' seconds')::interval;
  
  if request_count >= p_max_requests then
    return false;
  end if;
  
  insert into request_logs (user_id, endpoint)
  values (p_user_id, p_endpoint);
  
  return true;
end;
$$ language plpgsql;

-- Clean up old logs daily
select cron.schedule(
  'cleanup-request-logs',
  '0 0 * * *',
  $$delete from request_logs where created_at < now() - interval '7 days'$$
);

Data Encryption and Privacy

sqlencryption.sql
-- 1. Enable pgcrypto extension
create extension if not exists pgcrypto;

-- 2. Encrypt sensitive columns
create table user_secrets (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users(id),
  ssn text, -- Encrypted
  credit_card text, -- Encrypted
  created_at timestamp with time zone default now()
);

-- Function to encrypt data
create or replace function encrypt_data(data text, key text)
returns text as $$
begin
  return encode(
    pgp_sym_encrypt(data, key),
    'base64'
  );
end;
$$ language plpgsql;

-- Function to decrypt data
create or replace function decrypt_data(encrypted text, key text)
returns text as $$
begin
  return pgp_sym_decrypt(
    decode(encrypted, 'base64'),
    key
  );
end;
$$ language plpgsql;

-- Usage in application
// TypeScript client-side encryption
import CryptoJS from 'crypto-js'

const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!

function encryptData(data: string): string {
  return CryptoJS.AES.encrypt(data, ENCRYPTION_KEY).toString()
}

function decryptData(encrypted: string): string {
  const bytes = CryptoJS.AES.decrypt(encrypted, ENCRYPTION_KEY)
  return bytes.toString(CryptoJS.enc.Utf8)
}

// Save encrypted data
const encryptedSSN = encryptData('123-45-6789')
await supabase.from('user_secrets').insert({
  user_id: userId,
  ssn: encryptedSSN,
})

// Read and decrypt
const { data } = await supabase
  .from('user_secrets')
  .select('ssn')
  .eq('user_id', userId)
  .single()

const decryptedSSN = decryptData(data.ssn)

// 3. Hash passwords (Supabase does this automatically)
// Never store plain text passwords

-- 4. Anonymize PII for analytics
create table analytics_events (
  id uuid default gen_random_uuid() primary key,
  user_hash text, -- Hashed user ID
  event_type text,
  created_at timestamp with time zone default now()
);

-- Hash user IDs
insert into analytics_events (user_hash, event_type)
values (
  encode(digest(auth.uid()::text, 'sha256'), 'hex'),
  'page_view'
);

-- 5. Secure file storage
const { data, error } = await supabase.storage
  .from('private-documents')
  .upload(`${userId}/${filename}`, file, {
    cacheControl: '3600',
    upsert: false,
  })

-- Storage RLS policies
create policy "Users can only access own files"
  on storage.objects for select
  using (auth.uid()::text = (storage.foldername(name))[1]);

Security Checklist

Security AreaActionPriority
Row Level SecurityEnable RLS on all tablesCritical
API KeysNever expose service_role keyCritical
Input ValidationValidate all user inputsCritical
AuthenticationEnforce strong passwordsHigh
MFAEnable Multi-Factor AuthHigh
Rate LimitingImplement request throttlingHigh
EncryptionEncrypt sensitive dataMedium
Audit LoggingLog security eventsMedium

Security Best Practices

  • Enable RLS Everywhere: Never allow public access without Row Level Security policies
  • Validate All Inputs: Use schemas like Zod for type-safe validation preventing injection attacks
  • Rotate Keys Regularly: Change API keys quarterly and immediately if compromised
  • Monitor Activity: Set up alerts for suspicious database operations or failed login attempts
  • Limit Permissions: Grant minimum required access using principle of least privilege
  • Encrypt Sensitive Data: Use pgcrypto for PII, payment info, or confidential data
  • Test Security: Regular penetration testing and security audits finding vulnerabilities
Critical Warning: Never commit API keys to version control. One exposed service_role key can grant attackers full database access bypassing all RLS policies. Use environment variables and rotate keys immediately if exposed. Review RLS policies carefully.

Common Security Issues

  • RLS Bypassed: Ensure RLS is enabled with alter table enable row level security
  • Unauthorized Access: Verify policies use auth.uid() correctly and test with multiple users
  • API Key Exposed: Rotate keys immediately in Project Settings > API and update all apps
  • Slow Policies: Add indexes on columns used in RLS policy predicates for performance

Next Steps

  1. Implement RLS: Master Row Level Security policies
  2. Test Security: Write security tests validating policies
  3. Backup Data: Implement backup strategies
  4. Optimize Performance: Secure performance optimization

Conclusion

Securing Supabase applications protects sensitive data and maintains user trust through Row Level Security enforcing granular access control, API key management preventing credential exposure, input validation preventing SQL injection, strong authentication with MFA, rate limiting preventing abuse, and data encryption protecting confidential information. By enabling RLS on all tables with policies using auth.uid() for user isolation, securing API keys in environment variables never committing to version control, validating all inputs with schemas like Zod, implementing strong password requirements with MFA enrollment, adding rate limiting preventing brute force attacks, encrypting sensitive data with pgcrypto, monitoring database activity for suspicious patterns, and regularly auditing security posture, you build production-ready secure applications. Security fundamentals include enabling RLS everywhere preventing public access, validating inputs comprehensively blocking injection attacks, rotating keys regularly minimizing breach impact, monitoring activity detecting threats early, limiting permissions following least privilege, encrypting sensitive data protecting confidentiality, and testing security through penetration testing. Always enable RLS before launching never allowing unrestricted access, validate inputs rigorously on both client and server, rotate keys immediately if compromised, monitor logs for anomalies, grant minimum required permissions, encrypt PII meeting compliance requirements, and test thoroughly verifying security controls. Security becomes critical when handling user data, processing payments, storing personal information, building multi-tenant applications, or achieving GDPR and HIPAA compliance. Continue with testing, backups, and local development.

$ cat /comments/ (0)

new_comment.sh

// Email hidden from public

>_

$ cat /comments/

// No comments found. Be the first!

[session] guest@{codershandbook}[timestamp] 2026

Navigation

Categories

Connect

Subscribe

// 2026 {Coders Handbook}. EOF.