Retour au blog
Jonathan Serra
14/12/2025
15 min

Supabase : les failles de sécurité critiques des requêtes SQL côté client

Exposer des requêtes SQL directement depuis le client JavaScript est une faille de sécurité majeure. Analyse technique des risques, de l'importance cruciale du RLS et des Edge Functions comme solution sécurisée.

SupabaseSécuritéRLSPostgreSQLEdge FunctionsSQL InjectionClient-SidePostgREST

Supabase offre une API REST automatique qui permet d'interroger directement votre base de données PostgreSQL depuis le code JavaScript côté client. Cette simplicité apparente cache cependant des risques de sécurité critiques si les bonnes pratiques ne sont pas respectées. Trop de développeurs, séduits par la facilité d'utilisation, exposent leurs données sans protection adéquate, créant des failles béantes dans leurs applications. Pour comprendre les coûts réels de Supabase, consultez notre analyse technique approfondie.

Cet article explore en détail les failles de sécurité liées aux requêtes SQL côté client, explique pourquoi le Row Level Security (RLS) est absolument crucial, et présente les Edge Functions comme solution sécurisée pour les opérations sensibles.

Le piège de la simplicité : requêtes SQL exposées côté client

Supabase génère automatiquement une API REST à partir de votre schéma PostgreSQL via PostgREST. Cette magie technique permet d'écrire des requêtes directement depuis le navigateur :

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(

'https://votre-projet.supabase.co',

'votre-cle-publique'

)

// Requête directe depuis le client

const { data, error } = await supabase

.from('users')

.select('*')

.eq('email', 'admin@example.com')

Cette approche est séduisante car elle élimine la nécessité d'un backend dédié pour les opérations CRUD simples. Cependant, cette simplicité masque un danger fondamental : votre clé API publique est visible dans le code source JavaScript, accessible à quiconque inspecte le code de votre application web.

Le problème de la clé API publique

La clé API publique de Supabase (anon key) est conçue pour être exposée côté client. Elle permet d'authentifier les requêtes, mais elle ne doit jamais être utilisée comme mécanisme de sécurité unique. Voici pourquoi :

// ❌ DANGEREUX : Clé API visible dans le code source

const SUPABASE_URL = 'https://votre-projet.supabase.co'

// Visible par tous !

const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

// N'importe qui peut extraire cette clé et l'utiliser

// depuis n'importe quel environnement

Un attaquant peut facilement extraire cette clé depuis les DevTools du navigateur, depuis le code source minifié (même obfusqué), ou depuis les outils de développement. Une fois en possession de cette clé, il peut effectuer des requêtes vers votre API Supabase depuis n'importe quel environnement, contournant potentiellement les restrictions CORS si elles ne sont pas correctement configurées.

Les attaques possibles sans RLS

Sans Row Level Security (RLS) activé, un attaquant peut potentiellement :

1. Lire toutes les données de toutes les tables

// Attaque depuis un script externe

const { data } = await supabase

.from('users')

.select('*') // Récupère TOUS les utilisateurs

const { data: orders } = await supabase

.from('orders')

.select('*') // Récupère TOUTES les commandes

const { data: payments } = await supabase

.from('payments')

.select('*') // Récupère TOUS les paiements

2. Modifier ou supprimer des données

// Mise à jour malveillante

await supabase

.from('users')

.update({ role: 'admin' })

.eq('id', 'user-id-attacker')

// Suppression de données

await supabase

.from('orders')

.delete()

.gte('created_at', '2024-01-01')

3. Effectuer des injections SQL via PostgREST

Bien que PostgREST offre une certaine protection contre les injections SQL classiques, des attaques sophistiquées peuvent exploiter les fonctionnalités de filtrage :

// Tentative d'injection via les opérateurs PostgREST

const maliciousInput = "admin' OR '1'='1"

const { data } = await supabase

.from('users')

.select('*')

.or(email.eq.${maliciousInput},role.eq.admin)

4. Consommer des ressources et générer des coûts

Un attaquant peut lancer des requêtes massives pour saturer votre base de données et générer des coûts importants :

// Attaque par déni de service

for (let i = 0; i < 10000; i++) {

await supabase

.from('users')

.select('*')

.limit(1000)

}

Row Level Security (RLS) : la protection essentielle

Le Row Level Security (RLS) est une fonctionnalité native de PostgreSQL qui permet de définir des politiques de sécurité au niveau des lignes de données. Avec RLS activé, même si un utilisateur a accès à une table, il ne peut voir ou modifier que les lignes autorisées par les politiques définies.

Activer RLS sur une table

La première étape consiste à activer RLS sur chaque table sensible :

-- Activer RLS sur la table users

ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Activer RLS sur la table orders

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Activer RLS sur la table payments

ALTER TABLE payments ENABLE ROW LEVEL SECURITY;

Important : Une fois RLS activé, par défaut, toutes les opérations (SELECT, INSERT, UPDATE, DELETE) sont bloquées. Vous devez créer des politiques explicites pour autoriser les accès nécessaires.

Créer des politiques RLS basiques

Les politiques RLS utilisent des expressions SQL qui retournent un booléen. Voici des exemples de politiques courantes :

-- Politique : les utilisateurs ne peuvent voir que leur propre profil

CREATE POLICY "Users can view own profile"

ON users

FOR SELECT

USING (auth.uid() = id);

-- Politique : les utilisateurs peuvent mettre à jour leur propre profil

CREATE POLICY "Users can update own profile"

ON users

FOR UPDATE

USING (auth.uid() = id)

WITH CHECK (auth.uid() = id);

-- Politique : les utilisateurs peuvent voir leurs propres commandes

CREATE POLICY "Users can view own orders"

ON orders

FOR SELECT

USING (auth.uid() = user_id);

-- Politique : les utilisateurs peuvent créer leurs propres commandes

CREATE POLICY "Users can create own orders"

ON orders

FOR INSERT

WITH CHECK (auth.uid() = user_id);

La fonction auth.uid() retourne l'ID de l'utilisateur actuellement authentifié via Supabase Auth. Cette fonction est cruciale pour lier les données à l'utilisateur.

Politiques RLS avancées

Pour des cas d'usage plus complexes, vous pouvez créer des politiques sophistiquées :

-- Politique : les administrateurs peuvent tout voir

CREATE POLICY "Admins can view all users"

ON users

FOR SELECT

USING (

EXISTS (

SELECT 1 FROM users

WHERE id = auth.uid()

AND role = 'admin'

)

);

-- Politique : les utilisateurs peuvent voir les commandes de leur équipe

CREATE POLICY "Users can view team orders"

ON orders

FOR SELECT

USING (

user_id IN (

SELECT id FROM users

WHERE team_id = (

SELECT team_id FROM users

WHERE id = auth.uid()

)

)

);

-- Politique : les utilisateurs peuvent voir les produits publics ou leurs propres produits

CREATE POLICY "Users can view public or own products"

ON products

FOR SELECT

USING (

is_public = true

OR owner_id = auth.uid()

);

Les pièges courants du RLS

Même avec RLS activé, des erreurs de configuration peuvent créer des failles :

1. Oublier d'activer RLS sur certaines tables

-- ❌ DANGEREUX : Table sans RLS

CREATE TABLE api_keys (

id UUID PRIMARY KEY,

user_id UUID REFERENCES users(id),

key_value TEXT NOT NULL

);

-- RLS non activé = données accessibles à tous !

2. Politiques trop permissives

-- ❌ DANGEREUX : Politique trop large

CREATE POLICY "Everyone can view users"

ON users

FOR SELECT

USING (true); -- Autorise TOUS les utilisateurs à voir TOUS les profils

3. Oublier les politiques UPDATE/DELETE

-- ⚠️ INCOMPLET : Seulement SELECT protégé

CREATE POLICY "Users can view own data"

ON sensitive_data

FOR SELECT

USING (auth.uid() = user_id);

-- Mais UPDATE et DELETE ne sont pas protégés !

4. Utiliser des fonctions non sécurisées dans les politiques

-- ❌ DANGEREUX : Fonction qui peut être contournée

CREATE OR REPLACE FUNCTION is_admin(user_id UUID)

RETURNS BOOLEAN AS $$

BEGIN

-- Cette fonction peut être appelée directement et contournée

RETURN EXISTS (

SELECT 1 FROM users WHERE id = user_id AND role = 'admin'

);

END;

$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE POLICY "Admins can do anything"

ON sensitive_table

FOR ALL

USING (is_admin(auth.uid())); -- Risque si la fonction est mal sécurisée

Tester vos politiques RLS

Il est crucial de tester vos politiques RLS pour s'assurer qu'elles fonctionnent correctement :

// Test : un utilisateur ne peut pas voir les données d'un autre

const { data: otherUser } = await supabase

.from('users')

.select('*')

.eq('id', 'autre-user-id')

.single()

// Devrait retourner null ou une erreur si RLS est correctement configuré

if (otherUser) {

console.error('❌ FAILLE : RLS ne fonctionne pas correctement')

}

// Test : un utilisateur peut voir ses propres données

const { data: ownData } = await supabase

.from('users')

.select('*')

.eq('id', currentUser.id)

.single()

if (!ownData) {

console.error('❌ PROBLÈME : RLS bloque même les données propres')

}

Edge Functions : la solution pour les opérations sensibles

Même avec un RLS parfaitement configuré, certaines opérations ne devraient jamais être effectuées directement depuis le client. Les Edge Functions de Supabase permettent d'exécuter du code serverless à la périphérie du réseau, offrant un environnement sécurisé pour les opérations critiques.

Pourquoi utiliser des Edge Functions ?

1. Logique métier complexe

Les Edge Functions permettent d'implémenter une logique métier complexe qui ne devrait pas être exposée côté client :

// ❌ DANGEREUX : Logique de calcul côté client

const calculatePrice = (basePrice: number, discount: number) => {

// Un attaquant peut modifier cette logique dans le navigateur

return basePrice * (1 - discount)

}

// ✅ SÉCURISÉ : Logique dans une Edge Function

// supabase/functions/calculate-price/index.ts

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {

const { basePrice, discount } = await req.json()

// Logique sécurisée côté serveur

const finalPrice = basePrice * (1 - discount)

// Validation et vérifications supplémentaires

if (discount > 0.5) {

return new Response(

JSON.stringify({ error: 'Discount too high' }),

{ status: 400 }

)

}

return new Response(JSON.stringify({ price: finalPrice }))

})

2. Accès à des clés API secrètes

Les Edge Functions ont accès aux variables d'environnement et aux clés secrètes qui ne doivent jamais être exposées côté client :

// supabase/functions/send-email/index.ts

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {

// Récupération de la clé secrète (jamais exposée au client)

const supabaseUrl = Deno.env.get('SUPABASE_URL')!

const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!

const supabase = createClient(supabaseUrl, supabaseServiceKey)

const { to, subject, body } = await req.json()

// Utilisation d'un service d'email externe avec clé API secrète

const emailApiKey = Deno.env.get('EMAIL_SERVICE_API_KEY')!

const response = await fetch('https://api.email-service.com/send', {

method: 'POST',

headers: {

'Authorization': Bearer ${emailApiKey},

'Content-Type': 'application/json'

},

body: JSON.stringify({ to, subject, body })

})

return new Response(JSON.stringify({ success: true }))

})

3. Opérations batch et transactions

Les Edge Functions permettent d'effectuer des opérations batch complexes dans une transaction :

// supabase/functions/process-order/index.ts

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {

const supabaseUrl = Deno.env.get('SUPABASE_URL')!

const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!

const supabase = createClient(supabaseUrl, supabaseServiceKey)

const { orderId } = await req.json()

// Récupération de la commande avec vérification

const { data: order, error: orderError } = await supabase

.from('orders')

.select(', order_items()')

.eq('id', orderId)

.single()

if (orderError || !order) {

return new Response(

JSON.stringify({ error: 'Order not found' }),

{ status: 404 }

)

}

// Vérification du stock pour tous les articles

for (const item of order.order_items) {

const { data: product } = await supabase

.from('products')

.select('stock')

.eq('id', item.product_id)

.single()

if (!product || product.stock < item.quantity) {

return new Response(

JSON.stringify({ error: 'Insufficient stock' }),

{ status: 400 }

)

}

}

// Transaction : mise à jour du stock et création du paiement

const { error: transactionError } = await supabase.rpc('process_order_transaction', {

order_id: orderId

})

if (transactionError) {

return new Response(

JSON.stringify({ error: transactionError.message }),

{ status: 500 }

)

}

return new Response(JSON.stringify({ success: true }))

})

4. Validation et sanitization avancées

Les Edge Functions permettent d'implémenter des validations complexes qui ne peuvent pas être contournées :

// supabase/functions/create-user/index.ts

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

import { z } from 'https://deno.land/x/zod/mod.ts'

const createUserSchema = z.object({

email: z.string().email(),

password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),

name: z.string().min(2).max(100)

})

serve(async (req) => {

try {

const body = await req.json()

const validatedData = createUserSchema.parse(body)

const supabaseUrl = Deno.env.get('SUPABASE_URL')!

const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!

const supabase = createClient(supabaseUrl, supabaseServiceKey)

// Vérification de l'unicité de l'email

const { data: existingUser } = await supabase

.from('users')

.select('id')

.eq('email', validatedData.email)

.single()

if (existingUser) {

return new Response(

JSON.stringify({ error: 'Email already exists' }),

{ status: 400 }

)

}

// Création de l'utilisateur avec hash du mot de passe

const { data: newUser, error } = await supabase.auth.admin.createUser({

email: validatedData.email,

password: validatedData.password,

user_metadata: { name: validatedData.name }

})

if (error) {

return new Response(

JSON.stringify({ error: error.message }),

{ status: 500 }

)

}

return new Response(JSON.stringify({ user: newUser }))

} catch (error) {

if (error instanceof z.ZodError) {

return new Response(

JSON.stringify({ error: 'Validation failed', details: error.errors }),

{ status: 400 }

)

}

return new Response(

JSON.stringify({ error: 'Internal server error' }),

{ status: 500 }

)

}

})

Appeler une Edge Function depuis le client

Depuis le code client, vous appelez une Edge Function de manière sécurisée :

// Appel depuis le client

const { data, error } = await supabase.functions.invoke('calculate-price', {

body: { basePrice: 100, discount: 0.1 }

})

if (error) {

console.error('Error:', error)

} else {

console.log('Price:', data.price)

}

L'Edge Function reçoit automatiquement le token JWT de l'utilisateur authentifié, permettant de vérifier son identité :

// Dans l'Edge Function

serve(async (req) => {

// Récupération du token JWT depuis les headers

const authHeader = req.headers.get('Authorization')

if (!authHeader) {

return new Response(

JSON.stringify({ error: 'Unauthorized' }),

{ status: 401 }

)

}

const token = authHeader.replace('Bearer ', '')

// Vérification du token avec Supabase

const supabaseUrl = Deno.env.get('SUPABASE_URL')!

const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!

const supabase = createClient(supabaseUrl, supabaseAnonKey)

const { data: { user }, error } = await supabase.auth.getUser(token)

if (error || !user) {

return new Response(

JSON.stringify({ error: 'Invalid token' }),

{ status: 401 }

)

}

// Logique sécurisée avec utilisateur authentifié

// ...

})

Bonnes pratiques de sécurité avec Supabase

Pour sécuriser correctement votre application Supabase, suivez ces bonnes pratiques :

1. Activer RLS sur toutes les tables sensibles

-- Script pour activer RLS sur toutes les tables

DO $$

DECLARE

r RECORD;

BEGIN

FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP

EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', r.tablename);

END LOOP;

END $$;

2. Utiliser des politiques RLS restrictives par défaut

-- Politique par défaut : tout est bloqué

-- Puis ajouter des exceptions explicites

CREATE POLICY "Users can only see own data"

ON sensitive_data

FOR SELECT

USING (auth.uid() = user_id);

3. Ne jamais exposer la clé service_role côté client

// ❌ JAMAIS dans le code client

const SUPABASE_SERVICE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'

// ✅ Uniquement dans les Edge Functions ou backend

// Via Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')

4. Valider toutes les entrées utilisateur

// Utiliser des bibliothèques de validation comme Zod

import { z } from 'zod'

const userInputSchema = z.object({

email: z.string().email(),

age: z.number().min(0).max(120)

})

const validated = userInputSchema.parse(userInput)

5. Limiter les requêtes avec des limites

// Toujours limiter le nombre de résultats

const { data } = await supabase

.from('posts')

.select('*')

.limit(100) // Limite explicite

6. Utiliser des index pour les performances RLS

-- Créer des index sur les colonnes utilisées dans les politiques RLS

CREATE INDEX idx_users_id ON users(id);

CREATE INDEX idx_orders_user_id ON orders(user_id);

CREATE INDEX idx_products_owner_id ON products(owner_id);

Conclusion

Supabase offre une expérience de développement remarquable, mais cette simplicité ne doit pas se faire au détriment de la sécurité. Exposer des requêtes SQL directement depuis le client sans protection adéquate est une faille de sécurité critique qui peut mener à l'exposition de données sensibles, à des modifications non autorisées, et à des attaques par déni de service.

Le Row Level Security (RLS) est absolument essentiel pour protéger vos données. Il doit être activé sur toutes les tables sensibles, avec des politiques restrictives et bien testées. Cependant, RLS seul ne suffit pas pour toutes les opérations. Les Edge Functions offrent un environnement sécurisé pour la logique métier complexe, l'accès aux clés API secrètes, et les opérations transactionnelles.

La règle d'or : si une opération implique de la logique métier complexe, des clés secrètes, ou des validations critiques, elle doit être effectuée dans une Edge Function ou un backend dédié, jamais directement depuis le client. La sécurité n'est pas optionnelle, et les quelques minutes supplémentaires passées à configurer correctement RLS et à créer des Edge Functions peuvent éviter des catastrophes coûteuses et irréversibles.

Prêt à passer votre projet vibe code en production ?

Découvrez comment nous pouvons vous aider à réduire vos coûts et améliorer vos performances