Leçon 7/7 12 min

Concevoir et sécuriser

Créez des tables avec CREATE TABLE, faites-les évoluer avec ALTER TABLE, accélérez avec les index, puis protégez-vous des injections SQL avec les requêtes préparées.

Le problème : avant de lire, il faut construire (sans laisser la porte ouverte)

Jusqu'ici, les tables existaient déjà. Mais c'est vous qui les créez. Et le jour où votre site connecte ces tables à un formulaire, une faille béante peut s'ouvrir : un visiteur mal intentionné peut, via une simple zone de texte, lire ou détruire toute votre base. Ça s'appelle l'injection SQL, et c'est encore aujourd'hui l'une des failles les plus exploitées du web.

Cette leçon couvre les deux faces : concevoir une table propre, puis la sécuriser quand des données utilisateur entrent en jeu.

CREATE TABLE : types, clé primaire, clé étrangère

On définit une table en listant ses colonnes, chacune avec un type :

CREATE TABLE clients (
    id         INT          PRIMARY KEY AUTO_INCREMENT,
    nom        VARCHAR(100) NOT NULL,
    email      VARCHAR(150) NOT NULL UNIQUE,
    ville      VARCHAR(80),
    inscrit_le DATE         DEFAULT (CURRENT_DATE)
);

Les types les plus courants : INT (entier), DECIMAL(8,2) (nombre à décimales, idéal pour un prix), VARCHAR(n) (texte court), TEXT (texte long), DATE et DATETIME. Les contraintes NOT NULL, UNIQUE et DEFAULT protègent la cohérence des données dès l'écriture.

Une table reliée déclare sa clé étrangère :

CREATE TABLE commandes (
    id        INT     PRIMARY KEY AUTO_INCREMENT,
    client_id INT     NOT NULL,
    montant   DECIMAL(8,2) NOT NULL,
    FOREIGN KEY (client_id) REFERENCES clients(id)
);
La clé étrangère client_id de la table commandes pointe vers la clé primaire id de la table clients. FOREIGN KEY (client_id) → clients(id) clients id nom 1 Alice 2 Karim commandes id client_id montant 1 1 50 2 1 30 3 2 80
La clé étrangère client_id pointe vers la clé primaire id de clients : chaque commande est reliée à un client existant.

Pourquoi une clé étrangère plutôt que recopier le client ? On stocke client_id dans commandes au lieu de recopier le nom et l'email du client dans chaque commande. Recopier dupliquerait la même information sur des dizaines de lignes : si Alice change d'email, il faudrait corriger toutes ses commandes une par une, et le moindre oubli crée une incohérence. Avec la clé étrangère, l'information du client vit à un seul endroit (la table clients) : un seul endroit à corriger, zéro duplication, zéro anomalie de mise à jour.

ALTER TABLE : faire évoluer sans tout casser

Le schéma du premier jour ne survit jamais. Trois semaines après le lancement, le client veut stocker le numéro de téléphone. Pas question de tout refaire : ALTER TABLE modifie une table existante sans toucher aux données.

ALTER TABLE clients ADD COLUMN telephone TEXT;

La colonne s'ajoute à droite, toutes les lignes existantes ont NULL dans ce champ jusqu'à la prochaine mise à jour. Vous pouvez aussi renommer une colonne (RENAME COLUMN) ou en changer le type (MODIFY COLUMN en MySQL, ALTER COLUMN en PostgreSQL).

Supprimer une colonne, c'est perdre ses données. ALTER TABLE clients DROP COLUMN telephone efface définitivement toutes les valeurs stockées dans cette colonne. Pas de corbeille, pas d'annulation possible après validation. Avant de supprimer : vérifiez que plus aucun code n'utilise cette colonne, sauvegardez, et préférez une mise hors service progressive (ne plus écrire dedans avant de la supprimer).

Testez ALTER TABLE : créez une table, insérez une ligne, ajoutez une colonne, puis relisez. Observez que la colonne ajoutée contient NULL sur la ligne existante.

CREATE TABLE clients (id INTEGER PRIMARY KEY, nom TEXT);
INSERT INTO clients VALUES (1, 'Alice Durand');

ALTER TABLE clients ADD COLUMN telephone TEXT;

SELECT * FROM clients;

L'index : l'annuaire de ta table

Sans index, chercher un client par ville oblige la base à lire chaque ligne de la table, une par une, jusqu'à la fin. C'est le scan complet : acceptable sur 100 lignes, catastrophique sur un million.

Un index crée une structure séparée et triée sur la colonne, comme un annuaire alphabétique. La base y saute directement à la bonne entrée au lieu de tout parcourir.

CREATE INDEX idx_clients_ville ON clients(ville);

Quand créer un index ? Sur les colonnes qui apparaissent fréquemment dans les WHERE ou les JOIN. Si vous filtrez souvent par ville, un index sur ville accélère toutes ces requêtes d'un coup.

Un index a un coût à l'écriture. À chaque INSERT, UPDATE ou DELETE, la base doit aussi mettre à jour l'index. N'indexez pas toutes les colonnes par précaution : concentrez-vous sur celles vraiment utilisées en filtre ou en jointure. Une table avec vingt index écrira vingt fois plus lentement.

EXPLAIN (ou EXPLAIN QUERY PLAN en SQLite) montre si la base utilise réellement votre index ou fait quand même un scan complet : c'est l'outil de première intention quand une requête est plus lente que prévu.

L'injection SQL : la faille qui vide votre base

Imaginez ce code PHP qui cherche un client par email, en collant directement la saisie de l'utilisateur dans la requête :

Prédisez avant de lire

Un code de login construit sa requête ainsi : "SELECT * FROM users WHERE nom = '" + saisie + "'". L'attaquant saisit comme nom : ' OR '1'='1. Avant de dérouler : à quoi ressemble la requête réellement exécutée, et que renvoie-t-elle ? Pourquoi est-ce dangereux ?

Voir la réponse

La requête devient SELECT * FROM users WHERE nom = '' OR '1'='1'. La condition '1'='1' est toujours vraie : le WHERE est vrai pour chaque ligne, donc la requête renvoie tous les utilisateurs. L'attaquant contourne l'authentification sans mot de passe valide et peut lire toute la table. La cause : la saisie a été traitée comme du code SQL, pas comme une simple donnée. Le remède vu juste après : les requêtes préparées, où la saisie est transmise séparément et ne peut jamais modifier la structure de la requête.

// CODE VULNÉRABLE : NE JAMAIS FAIRE ÇA
$email = $_POST['email'];
$sql = "SELECT * FROM clients WHERE email = '$email'";
$resultat = $pdo->query($sql);

Si l'utilisateur tape un email normal, ça marche. Mais s'il saisit :

' OR '1'='1

La requête envoyée devient :

SELECT * FROM clients WHERE email = '' OR '1'='1'

'1'='1' est toujours vrai : la requête renvoie tous les clients. Avec une saisie plus agressive ('; DROP TABLE clients; --), l'attaquant peut carrément supprimer la table.

Ne concaténez JAMAIS une saisie utilisateur dans une requête SQL. Tout ce qui vient de l'extérieur (formulaire, URL, en-tête HTTP) est hostile par défaut. Coller cette donnée dans une chaîne SQL, c'est laisser l'utilisateur réécrire votre requête.

Le remède : les requêtes préparées

La solution s'appelle requête préparée (prepared statement). Le principe : on envoie d'abord la structure de la requête avec des placeholders, puis les valeurs séparément. La base traite alors la saisie comme une simple donnée, jamais comme du code SQL.

// CODE SÉCURISÉ : requête préparée (PDO)
$email = $_POST['email'];
$stmt = $pdo->prepare("SELECT * FROM clients WHERE email = :email");
$stmt->execute(['email' => $email]);
$resultat = $stmt->fetchAll();

Ici, :email est un placeholder. Même si l'utilisateur tape ' OR '1'='1, cette chaîne est cherchée telle quelle comme valeur d'email : elle ne devient jamais du code. La faille disparaît.

Toujours utiliser des requêtes préparées dès qu'une donnée externe entre dans une requête. C'est la règle de sécurité numéro un en SQL. Les placeholders (:nom avec PDO, ou ? positionnels) séparent le code des données, ce qui rend l'injection impossible sur les valeurs. Une limite à connaître : un placeholder remplace seulement une valeur, jamais un nom de table ou de colonne. Si l'un d'eux doit être dynamique (ex. choisir la colonne de tri), ne le concaténez pas tel quel : validez-le contre une liste blanche de noms autorisés.

Lien avec l'IA : l'IA produit souvent, par défaut, du code qui concatène les variables dans la requête, pile la faille ci-dessus. Ne lui faites pas une confiance aveugle : exigez explicitement des requêtes préparées dans votre prompt, et relisez chaque requête générée pour vérifier qu'aucune variable n'est collée directement dans la chaîne SQL.

Récapitulatif : tout le SQL en main

C'est la dernière leçon du cours. Vous êtes parti de zéro et vous savez désormais lire, filtrer, agréger, relier, modifier et concevoir une base. Le meilleur moyen de l'ancrer n'est pas de relire : c'est de vérifier, de tête, que chaque brique répond présent. Sans remonter dans les leçons, sauriez-vous écrire :

  • Lire (leçon 2, SELECT) : une requête qui sort le nom et l'email de tous les clients ?
  • Filtrer et trier (leçon 3, WHERE / ORDER BY) : les clients de Paris, du plus récent inscrit au plus ancien ?
  • Agréger (leçon 4, GROUP BY / HAVING) : le total commandé par client, en ne gardant que ceux qui dépassent 100 € ?
  • Relier (leçon 5, JOIN) : le nom du client à côté du montant de chacune de ses commandes ?
  • Modifier (leçon 6, INSERT / UPDATE / DELETE) : insérer un client, corriger son email, puis supprimer une commande ?
  • Concevoir et sécuriser (cette leçon, CREATE TABLE / ALTER TABLE / index / requête préparée) : créer la table commandes avec sa clé étrangère, ajouter une colonne plus tard sans perdre les données, créer un index sur ville, puis chercher un client par email sans risquer l'injection ?

Chaque « oui, je saurais » est une brique solide. Pour chaque « euh… », revenez à la leçon correspondante : c'est exactement là qu'un quart d'heure de relecture vaut le plus. Et la règle qui les surplombe toutes : dès qu'une donnée vient de l'extérieur, requête préparée, jamais de concaténation.

The problem: before reading, you must build (without leaving the door open)

So far, the tables already existed. But you are the one creating them. And the day your site connects these tables to a form, a gaping hole can open: a malicious visitor can, through a simple text field, read or destroy your entire database. It is called SQL injection, and it is still today one of the most exploited web vulnerabilities.

This lesson covers both sides: designing a clean table, then securing it when user data comes into play.

CREATE TABLE: types, primary key, foreign key

You define a table by listing its columns, each with a type:

CREATE TABLE clients (
    id         INT          PRIMARY KEY AUTO_INCREMENT,
    nom        VARCHAR(100) NOT NULL,
    email      VARCHAR(150) NOT NULL UNIQUE,
    ville      VARCHAR(80),
    inscrit_le DATE         DEFAULT (CURRENT_DATE)
);

The most common types: INT (integer), DECIMAL(8,2) (decimal number, ideal for a price), VARCHAR(n) (short text), TEXT (long text), DATE and DATETIME. The constraints NOT NULL, UNIQUE and DEFAULT protect data consistency from the moment of writing.

A related table declares its foreign key:

CREATE TABLE commandes (
    id        INT     PRIMARY KEY AUTO_INCREMENT,
    client_id INT     NOT NULL,
    montant   DECIMAL(8,2) NOT NULL,
    FOREIGN KEY (client_id) REFERENCES clients(id)
);
The foreign key client_id in the orders table points to the primary key id in the clients table. FOREIGN KEY (client_id) → clients(id) clients id name 1 Alice 2 Karim orders id client_id amount 1 1 50 2 1 30 3 2 80
The foreign key client_id points to the primary key id in clients: each order links to an existing client.

Why a foreign key instead of copying the customer? We store client_id in commandes instead of copying the customer's name and email into every order. Copying would duplicate the same information across dozens of rows: if Alice changes her email, you would have to fix all her orders one by one, and a single miss creates an inconsistency. With the foreign key, the customer's information lives in one place (the clients table): one place to fix, zero duplication, zero update anomaly.

ALTER TABLE: evolve without breaking everything

The day-one schema never survives. Three weeks after launch, the client wants to store a phone number. No need to start over: ALTER TABLE modifies an existing table without touching the data.

ALTER TABLE clients ADD COLUMN telephone TEXT;

The column is added on the right; all existing rows have NULL in that field until their next update. You can also rename a column (RENAME COLUMN) or change its type (MODIFY COLUMN in MySQL, ALTER COLUMN in PostgreSQL).

Dropping a column means losing its data. ALTER TABLE clients DROP COLUMN telephone permanently erases all values stored in that column. No recycle bin, no undo after commit. Before dropping: verify no code still uses that column, take a backup, and prefer a graceful retirement (stop writing to it before you drop it).

The index: your table's phonebook

Without an index, looking up a customer by city forces the database to read every row in the table, one by one, to the end. That is the full scan: fine on 100 rows, disastrous on a million.

An index creates a separate, sorted structure on the column — like an alphabetical phonebook. The database jumps straight to the right entry instead of scanning everything.

CREATE INDEX idx_clients_ville ON clients(ville);

When should you create an index? On columns that appear frequently in WHERE clauses or JOINs. If you often filter by city, a single index on ville speeds up all those queries at once.

An index has a write cost. For every INSERT, UPDATE, or DELETE, the database must also update the index. Do not index every column as a precaution: focus on the ones actually used for filtering or joining. A table with twenty indexes will write twenty times slower.

EXPLAIN (or EXPLAIN QUERY PLAN in SQLite) shows whether the database is actually using your index or still doing a full scan: it is the first tool to reach for when a query is slower than expected.

SQL injection: the flaw that empties your database

Imagine this PHP code searching for a customer by email, pasting the user input directly into the query:

Predict before reading

A login form builds its query this way: "SELECT * FROM users WHERE nom = '" + input + "'". The attacker types as the name: ' OR '1'='1. Before you scroll: what does the query actually executed look like, and what does it return? Why is this dangerous?

See the answer

The query becomes SELECT * FROM users WHERE nom = '' OR '1'='1'. The condition '1'='1' is always true: the WHERE clause is true for every row, so the query returns all users. The attacker bypasses authentication without a valid password and can read the entire table. The cause: the input was treated as SQL code, not as plain data. The fix seen just below: prepared statements, where the input is passed separately and can never alter the structure of the query.

// VULNERABLE CODE - NEVER DO THIS
$email = $_POST['email'];
$sql = "SELECT * FROM clients WHERE email = '$email'";
$resultat = $pdo->query($sql);

If the user types a normal email, it works. But if they enter:

' OR '1'='1

The sent query becomes:

SELECT * FROM clients WHERE email = '' OR '1'='1'

'1'='1' is always true: the query returns all customers. With a more aggressive input ('; DROP TABLE clients; --), the attacker can simply delete the table.

NEVER concatenate user input into a SQL query. Anything from the outside (form, URL, HTTP header) is hostile by default. Pasting that data into a SQL string lets the user rewrite your query.

The cure: prepared statements

The solution is the prepared statement. The principle: you first send the query structure with placeholders, then the values separately. The database then treats the input as plain data, never as SQL code.

// SECURE CODE - prepared statement (PDO)
$email = $_POST['email'];
$stmt = $pdo->prepare("SELECT * FROM clients WHERE email = :email");
$stmt->execute(['email' => $email]);
$resultat = $stmt->fetchAll();

Here, :email is a placeholder. Even if the user types ' OR '1'='1, this string is searched as-is as an email value — it never becomes code. The flaw disappears.

Always use prepared statements as soon as external data enters a query. This is the number-one SQL security rule. Placeholders (:name with PDO, or positional ?) separate code from data, which makes injection impossible on values. One limit to know: a placeholder only replaces a value, never a table or column name. If one of those must be dynamic (e.g. choosing the sort column), do not concatenate it as-is: validate it against a whitelist of allowed names.

Link with AI: AI often produces, by default, code that concatenates variables into the query — exactly the flaw above. Do not trust it blindly: explicitly demand prepared statements in your prompt, and review every generated query to check that no variable is pasted directly into the SQL string.

Recap: all the SQL you've got

This is the last lesson of the course. You started from scratch and you can now read, filter, aggregate, relate, modify and design a database. The best way to lock it in is not to reread: it's to check, from memory, that every building block answers the call. Without scrolling back into the lessons, could you write:

  • Read (lesson 2, SELECT): a query that returns the name and email of every customer?
  • Filter and sort (lesson 3, WHERE / ORDER BY): the customers from Paris, from the most recently registered to the oldest?
  • Aggregate (lesson 4, GROUP BY / HAVING): the total ordered per customer, keeping only those above €100?
  • Relate (lesson 5, JOIN): the customer's name next to the amount of each of their orders?
  • Modify (lesson 6, INSERT / UPDATE / DELETE): insert a customer, fix their email, then delete an order?
  • Design and secure (this lesson, CREATE TABLE / ALTER TABLE / indexes / prepared statement): create the commandes table with its foreign key, add a column later without losing data, create an index on ville, then look up a customer by email without risking injection?

Every "yes, I could" is a solid block. For every "uh…", go back to the matching lesson: that's exactly where fifteen minutes of rereading pays off most. And the rule that sits above them all: the moment data comes from the outside, prepared statement, never concatenation.

À vous d'essayer, la base est déjà remplie. Avant de cliquer sur Exécuter, prédisez : combien de lignes ce JOIN renvoie-t-il, et que contient chacune (nom du client et montant) ? Lancez ensuite et comparez.

CREATE TABLE clients (
  id INTEGER PRIMARY KEY,
  nom TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE
);
CREATE TABLE commandes (
  id INTEGER PRIMARY KEY,
  client_id INTEGER NOT NULL,
  montant REAL NOT NULL,
  FOREIGN KEY (client_id) REFERENCES clients(id)
);
INSERT INTO clients VALUES (1, 'Alice Durand', 'alice@exemple.fr');
INSERT INTO commandes VALUES (1, 1, 120.50);

SELECT clients.nom, commandes.montant
FROM clients
JOIN commandes ON commandes.client_id = clients.id;

🎯 Pratique

S'entraîner (clique pour ouvrir) :

Prompt IA
Avec l'IA

Faites auditer un code par l'IA pour repérer l'injection SQL, et exigez une version en requête préparée :

query($sql);. Question 1 : ce code est-il vulnérable à l'injection SQL ? Explique précisément l'attaque possible. Question 2 : réécris-le avec une requête préparée PDO utilisant un placeholder, sans jamais concaténer la saisie. Commente chaque ligne." data-i18n-en="You are a web security expert. Here is some PHP code: $email = $_POST['email']; $sql = \"SELECT * FROM clients WHERE email = '$email'\"; $pdo->query($sql);. Question 1: is this code vulnerable to SQL injection? Explain the possible attack precisely. Question 2: rewrite it with a PDO prepared statement using a placeholder, never concatenating the input. Comment each line.">Tu es un expert en sécurité web. Voici un bout de code PHP : $email = $_POST['email']; $sql = "SELECT * FROM clients WHERE email = '$email'"; $pdo->query($sql);. Question 1 : ce code est-il vulnérable à l'injection SQL ? Explique précisément l'attaque possible. Question 2 : réécris-le avec une requête préparée PDO utilisant un placeholder, sans jamais concaténer la saisie. Commente chaque ligne.
💬 Ré-explique sans regarder
Ré-explique sans regarder

Sans relire la réponse de l'IA : avec tes mots, pourquoi une requête préparée empêche l'injection alors qu'une requête concaténée ne le fait pas ?

Une bonne explication dit : avec la concaténation, la saisie est collée dans la chaîne SQL, donc ' OR '1'='1 devient du code exécuté. Avec une requête préparée, on envoie d'abord la structure avec un placeholder (:email), puis la valeur séparément : la base traite la saisie comme une simple donnée, jamais comme du SQL. Code et données sont séparés, donc l'injection est impossible sur les valeurs (un nom de table ou de colonne dynamique, lui, exige une liste blanche, pas un placeholder).
Exercice : Sécurisez la requête

Réécrivez le code vulnérable en requête préparée PDO. Votre code doit appeler prepare() avec un placeholder (:email ou ?) et execute() avec la valeur séparée. Aucune concaténation de $email dans la chaîne SQL.

⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Tu as demandé à l'IA une fonction PHP qui recherche un client par son nom. Ton rôle de relecteur : l'accepter telle quelle ou la rejeter, et dire pourquoi.

function chercherClient($pdo, $nom) {
    $sql = "SELECT * FROM clients WHERE nom = '" . $nom . "'";
    return $pdo->query($sql)->fetchAll();
}
À rejeter sans hésiter. Le paramètre $nom vient de l'utilisateur et il est concaténé directement dans la chaîne SQL : c'est exactement l'injection vue dans la leçon. Une saisie comme ' OR '1'='1 renvoie tous les clients, et '; DROP TABLE clients; -- peut détruire la table. Le fait que la fonction soit bien nommée et lisible ne change rien : il faut une requête préparée avec prepare() + execute() et un placeholder (:nom).
🧠 Rappel libre
Rappel libre

Sans remonter dans la leçon : qu'est-ce qu'une injection SQL, et quelle est la parade en une phrase ?

Une injection SQL, c'est quand une saisie utilisateur concaténée dans une requête est interprétée comme du code SQL (ex. ' OR '1'='1 qui renvoie toute la table). La parade : la requête préparée, qui envoie la structure avec un placeholder (:email) puis la valeur séparément, si bien que la saisie reste une donnée et ne devient jamais du SQL.
Qu'est-ce qu'une injection SQL ?
Comment se protéger d'une injection SQL ?
Vous avez une table avec 10 index. Quel est l'effet sur les écritures (INSERT, UPDATE, DELETE) ?
Prochaine étape

Vous savez désormais concevoir et interroger une base solide. Pour franchir un cap, il faut changer de façon de penser : organiser le code autour d'objets plutôt que d'instructions. Le cours sur la POO vous apprend à raisonner ainsi, tranquillement.

La POO expliquée simplement →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement