Database Security
The hotel with one master key 🏨
In a well-run hotel, a guest's key card opens one room and the gym. A cleaner's card opens the rooms of one floor. The master key sits in the manager's safe. If a guest's card is stolen, the thief gets one room, not the whole building.
Database security works the same way. Attackers rarely break the database itself; they come in through the application: a query that pastes user input into SQL, a password committed to a repository, a backup left in a public bucket. Once inside, they can do whatever the application's account is allowed to do. So most database security is about two things: keeping attackers from running their own SQL, and limiting what a compromised application account can do.
What you'll learn
- How SQL injection happens and why parameterized queries stop it; what to do for column names
- Least privilege with roles,
GRANTandREVOKE, and how privileges are inherited - Protecting credentials, and encryption in transit and at rest
- Audit trails that record who changed what, and that attackers cannot quietly erase
⚠️ Ethics first: these techniques are for defending systems and testing your own databases and applications, or ones you have written permission to test. Trying injection or stolen credentials against systems you do not own is a crime. Every example in this lesson runs against a small sample database the program creates itself.
SQL injection: when data becomes code
Here is a search the way it is too often written:
sql = "SELECT id, item FROM orders WHERE customer = '" + name + "'"
With name = "amal" the database receives ... WHERE customer = 'amal'. With name = "x' OR '1'='1" it receives ... WHERE customer = 'x' OR '1'='1'. The quote in the input closed the string, and OR '1'='1' became part of the SQL code: the condition is now true for every row, and every customer's orders are returned. Other inputs can change data or read other tables. Even an honest customer called D'Arcy breaks this query with a syntax error.
The fix is to keep code and data apart with a parameterized query (a prepared statement): the SQL text contains a placeholder, and the value is sent separately, so it can never be parsed as SQL.
db.execute("SELECT id, item FROM orders WHERE customer = ?", (name,)) # SQLite, Python
PostgreSQL: WHERE customer = $1 psycopg: WHERE customer = %s (a placeholder, NOT % formatting)
JDBC: WHERE customer = ? named: WHERE customer = :name
Rules from the OWASP SQL Injection Prevention Cheat Sheet, in its order of preference: use prepared statements with parameters; stored procedures are safe only if they do not build SQL from strings themselves; use allow-list validation where parameters cannot be used; escaping user input yourself is strongly discouraged.
Placeholders work for values only. Table and column names are identifiers and sort directions (ASC, DESC) are keywords: none of them can be bound. When the user picks a sort column, map their choice through a fixed allow-list to a column name you wrote yourself, and reject everything else. ORMs use parameters for you, but their "raw SQL" escape hatches are injectable in exactly the same way.
For a normal name the code part is the query you meant. For the malicious input the code part gains OR ''='': the input rewrote the query. For D'Arcy the quotes no longer balance, which is the syntax error a real database reports. With a placeholder the code part is identical for every input, because the value never enters the SQL text.
# What the database receives: user input pasted into SQL versus a fixed statement plus values.
def code_part(sql):
"""Blank out the inside of '...' literals: what remains is what the database runs as code."""
out, in_string = [], False
for ch in sql:
if ch == "'":
in_string = not in_string
out.append(ch)
elif not in_string:
out.append(ch)
return "".join(out)
for name in ["amal", "x' OR '1'='1", "d'arcy"]:
pasted = "SELECT id, item FROM orders WHERE customer = '" + name + "'"
print("input: ", name)
print(" pasted SQL: ", pasted)
print(" its code part: ", code_part(pasted))
statement = "SELECT id, item FROM orders WHERE customer = ?"
print()
print("parameterized code part, for EVERY input:", code_part(statement))
print("the input travels separately, as a value:", ("x' OR '1'='1",))Lab exercise 1
PythonGraded in Code LabA sample orders table exists (one customer is called d'arcy). find_orders(customer, sort) pastes both inputs into the SQL text, so it is injectable twice and crashes on an apostrophe.
Rewrite find_orders safely:
- pass
customeras a parameter (?) instead of pasting it sortis a column choice, which cannot be a parameter: mapid,itemandtotalto the columnsid,itemandtotal_centswith an allow-list, andraise ValueErrorfor anything else- keep the selected columns
id, item, total_centsand sort ascending
Input: line 1 is the customer name, line 2 the sort option. Output: rows: N and one line per order, or error: unsupported sort.
Example: amal and total print rows: 3, then Marker, Notebook and Backpack. The input x' OR '1'='1 must find 0 rows: it is just an odd name.
- A normal search sorted by item
- Injection input is treated as a plain name
- A real name with an apostrophe works
- The sort option is mapped through the allow-list
- A sort option outside the allow-list is refused
Example run
- Input
amal item
- Expected output
rows: 3 2 Backpack 30.00 6 Marker 4.40 1 Notebook 9.00
import sqlite3
import sys
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
item TEXT NOT NULL,
total_cents INTEGER NOT NULL
);
INSERT INTO orders (customer, item, total_cents) VALUESMore lines in Code Lab: 29
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Least privilege: roles, GRANT and REVOKE
Least privilege means every account gets exactly the permissions its job needs, and nothing more. If the application only reads products and writes orders, an attacker who steals its credentials (or finds an injection hole) can only read products and write orders: no DROP TABLE, no reading the staff table, no creating new users.
In PostgreSQL, users and groups are both roles. A common pattern is a group role that holds the permissions and a login role for the application that joins it:
CREATE ROLE shop_rw NOLOGIN; -- the permissions live here
GRANT CONNECT ON DATABASE shop TO shop_rw;
GRANT USAGE ON SCHEMA app TO shop_rw;
GRANT SELECT ON app.products TO shop_rw;
GRANT SELECT, INSERT, UPDATE ON app.orders, app.order_lines TO shop_rw;
CREATE ROLE shop_api LOGIN; -- the app logs in as this; password set separately
GRANT shop_rw TO shop_api; -- membership: shop_api inherits shop_rw's privileges
REVOKE ALL ON DATABASE shop FROM PUBLIC; -- PUBLIC means "every role"
How privileges add up:
- A role has the privileges granted to it, plus those of every role it is a member of (and their roles in turn), plus those granted to
PUBLIC, which every role belongs to. - Membership flows one way:
shop_apigainsshop_rw's privileges, never the reverse. REVOKEremoves only a direct grant. If the same privilege still arrives through a group role orPUBLIC, the role keeps it, a classic surprise when cleaning up access.- PostgreSQL 15 stopped letting
PUBLICcreate objects in thepublicschema by default; on older versions, revoke that yourself.
Practical rules:
- Separate accounts per job: the application (data changes only), migrations (schema changes, used only by deploys), read-only reporting, and personal accounts for humans. Never run the application as a superuser such as
postgres. - Expose a subset of columns through a view, and in PostgreSQL restrict rows with row-level security (
ALTER TABLE ... ENABLE ROW LEVEL SECURITYandCREATE POLICY). - Check what a role can really do: PostgreSQL's
has_table_privilege('shop_api', 'app.orders', 'DELETE')answers the same question as the next lab.
Lab exercise 2
PythonGraded in Code LabThe program models PostgreSQL privileges. It reads GRANT ... ON table TO role, GRANT group TO role (membership) and REVOKE ... ON table FROM role statements in order and keeps two dictionaries: grants[role] (a set of (privilege, table) granted directly) and member_of[role] (the roles it joined directly). For each CHECK role PRIVILEGE table line it calls has_privilege.
Write has_privilege(role, privilege, table) so it returns True when the privilege was granted:
- directly to the role, or
- to
PUBLIC(every role is a member of it), or - to any role the role is a member of, directly or through other roles (a chain like
alice→staff→analyst)
Membership never flows from a member back to its group. Guard against visiting a role twice.
Output: role PRIVILEGE table: allowed or denied per check.
Example: after GRANT SELECT ON products TO reader and GRANT reader TO shop_api, the check CHECK shop_api SELECT products prints allowed.
- Direct grants: allowed only what was granted
- A privilege inherited from a group role
- Inheritance through a chain of roles
- REVOKE removes only the direct grant
- Grants to PUBLIC apply to every role
- A group never inherits from its members
Example run
- Input
GRANT SELECT, INSERT ON orders TO shop_api CHECK shop_api INSERT orders CHECK shop_api DELETE orders CHECK shop_api SELECT customers
- Expected output
shop_api INSERT orders: allowed shop_api DELETE orders: denied shop_api SELECT customers: denied
import sys
# A model of PostgreSQL's privilege system, fed with simplified statements:
# GRANT SELECT, INSERT ON orders TO shop_api (table privileges)
# GRANT reader TO shop_api (role membership: shop_api joins reader)
# REVOKE INSERT ON orders FROM shop_api
# CHECK shop_api INSERT orders (ask: may this role do this now?)
grants = {} # role -> set of (privilege, table) granted DIRECTLY to that role
member_of = {} # role -> set of roles it is a member of (directly)
def has_privilege(role, privilege, table):More lines in Code Lab: 27
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Credentials and encryption
Credentials are the keys to the building. Keep them out of source code and out of the repository: load them from the environment or, better, from a secrets manager, with a different account and password for every environment (development, staging, production). Rotate them when people leave or a leak is suspected, and never print a connection string in logs. Use the strongest authentication the server offers: PostgreSQL uses scram-sha-256 password hashing by default since version 14, and a database should not accept connections from the whole internet at all, only from the application's network.
Encryption in transit (TLS) stops anyone on the network path from reading queries, results and passwords. Turning it on is not enough; the client must also verify the server, or an attacker in the middle can present their own certificate. PostgreSQL's client library calls this sslmode: the default prefer silently falls back to plain text, require encrypts without checking who answers (unless a root certificate file is present, in which case it verifies like verify-ca), and verify-full checks the certificate and the host name. On the server side, make encryption mandatory: hostssl lines in PostgreSQL's pg_hba.conf match only encrypted connections, so let remote clients in through hostssl lines only; MySQL has require_secure_transport.
Encryption at rest protects data files, disks and backups if they are stolen or mislaid: volume or disk encryption, and features such as Transparent Data Encryption in SQL Server and Oracle, or InnoDB's data-at-rest encryption in MySQL. Know its limit: the running database decrypts data for anyone who can log in, so at-rest encryption does nothing against a compromised application account. That is the job of least privilege. For a few especially sensitive fields you can add application-level encryption, with keys kept outside the database.
Passwords of your users are never stored, only hashed, with a slow, salted password-hashing function (OWASP recommends Argon2id, then scrypt or bcrypt), never a fast hash like plain SHA-256 and never reversible encryption.
The password is masked before anything is printed, which is how connection strings should appear in logs and error reports. The sslmode check flags require: the traffic is encrypted, but without verifying the server's certificate an attacker in the middle could still pose as the database. Change the string to verify-full or to the postgres user and run it again.
// Treat a connection string like a password: never log it raw, and check its TLS setting.
const dsn = "postgresql://shop_api:not-a-real-password@db.internal:5432/shop?sslmode=require";
const url = new URL(dsn);
const user = url.username;
if (url.password) url.password = "*****";
console.log("safe to log:", url.toString());
const mode = url.searchParams.get("sslmode") || "prefer"; // libpq's default when absent
const meaning = {
disable: "no encryption",
allow: "plain text first, encryption only if the server demands it",
prefer: "encryption if available, silent fallback to plain text",
require: "encrypted, but the server's identity is not verified",
"verify-ca": "encrypted, certificate checked, host name not checked",
"verify-full": "encrypted, certificate and host name checked",
};
console.log("sslmode=" + mode + ":", meaning[mode]);
if (mode !== "verify-full") console.log("advice: use sslmode=verify-full with the server's CA certificate");
console.log(["postgres", "root", "admin", "sa"].includes(user)
? "advice: never let the app log in as a superuser"
: "user " + user + ": a dedicated application role");Audit trails
When something goes wrong, the first questions are who changed what, and when? An audit trail answers them. Typical sources:
- Database logs: connections, failed logins, schema changes. In PostgreSQL, settings such as
log_connectionsand the pgAudit extension record sessions and statements; managed cloud databases offer audit logging as a setting. - Change history: a trigger that copies the old and new values of sensitive rows into an audit table, together with the acting user.
- Application events: "user 42 exported the customer list" is meaningful in the app, not in SQL.
UPDATE accounts SET balance = 70 WHERE id = 1
│
└─ AFTER UPDATE trigger ──▶ audit_log: UPDATE account 1: 100 -> 70 by amal, at 10:42
An audit trail is only worth something if an attacker cannot quietly edit it. The application account must not be allowed to UPDATE or DELETE audit rows (privileges again), the table can reject changes outright, and important logs are copied to a separate system the database accounts cannot reach. Remember the limit of triggers: anyone with rights to change the schema can drop them, which is one more reason the application must not hold those rights. Finally, record what you need and no more: an audit log full of passwords or card numbers becomes a target itself.
Lab exercise 3
PythonGraded in Code LabAn accounts table, an audit_log table and a one-row current_actor table (who is acting) exist. Your SQL in audit_sql runs once, then the program replays actions: balance updates, account deletions and two tampering attempts: rewrite (UPDATE audit_log ...) and tamper (DELETE FROM audit_log).
The trigger that logs balance updates is written for you. Add, in audit_sql:
- a trigger
AFTER DELETE ON accountsthat inserts'DELETE', the account id, the old balance,NULLas new balance and the current actor (in a DELETE trigger onlyOLDexists) - two triggers,
BEFORE UPDATEandBEFORE DELETE ON audit_log, that abort the statement withSELECT RAISE(ABORT, 'audit_log is append-only');
Input: lines as <user>, set <id> <balance>, delete <id>, rewrite or tamper.
Output: rewrite: blocked or tamper: blocked (or what was changed), then every audit entry and entries: N.
Example: as admin then delete 2 prints #1 DELETE account 2: 50 -> (deleted) by admin and entries: 1.
- A balance update is logged with its author
- A deleted account is logged with its old balance
- Erasing the audit log is blocked
- Covering tracks fails: the whole story stays
- Rewriting the audit log is blocked
- Deleting a missing account logs nothing
Example run
- Input
as amal set 1 70
- Expected output
#1 UPDATE account 1: 100 -> 70 by amal entries: 1
import sqlite3
import sys
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT NOT NULL, balance INTEGER NOT NULL);
INSERT INTO accounts (id, owner, balance) VALUES (1, 'amal', 100), (2, 'bilal', 50), (3, 'carla', 0);
-- Who is acting right now. In PostgreSQL you would read current_user or a session setting.
CREATE TABLE current_actor (username TEXT NOT NULL);
INSERT INTO current_actor (username) VALUES ('system');
More lines in Code Lab: 61
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
Why does a parameterized query stop SQL injection?
Question 2 of 5
A page lets users choose the column to sort by. How do you put it into the query safely?
Question 3 of 5
shop_api is a member of reader, which has SELECT on orders. You run REVOKE SELECT ON orders FROM shop_api. Can shop_api still read orders?
Question 4 of 5
An attacker steals the application's database credentials. Which measure limits the damage most?
Question 5 of 5
Your application connects to PostgreSQL with sslmode=require. What is still missing?
Recap and what to practice next
- Attackers usually reach the database through the application, so the goals are: no attacker-controlled SQL, and an application account that can do as little as possible.
- Parameterized queries keep data out of the SQL code; allow-lists handle identifiers such as sort columns. Never build SQL by pasting input, not even in an ORM's raw queries.
- Least privilege: separate roles per job, privileges granted to group roles, no superuser for the app. A role inherits from the roles it belongs to and from
PUBLIC, andREVOKEremoves only the direct grant. - Keep credentials in a secrets manager, encrypt in transit with server verification (
verify-full) and at rest (including backups), and hash users' passwords with a slow, salted function. - Audit trails record who changed what; protect them with privileges, append-only rules and a copy outside the database.
Practice next: in the first lab, add a direction option (asc or desc) through the same allow-list idea. In the privilege lab, add support for REVOKE group FROM role. Finally, revisit your backup plan with these eyes: every backup and replica holds the same data as the database, so it needs the same protection: encrypted, reachable only by the accounts that need it, and kept where a compromised application account cannot delete it.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2PythonNot passed yet Try in Code Lab
- Lab exercise 3PythonNot passed yet Try in Code Lab