🗄️Databases
Core skills · Lesson 8/8

Database Security

45 min 1 quiz Graded labs: 3

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, GRANT and REVOKE, 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 Lab

A 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 customer as a parameter (?) instead of pasting it
  • sort is a column choice, which cannot be a parameter: map id, item and total to the columns id, item and total_cents with an allow-list, and raise ValueError for anything else
  • keep the selected columns id, item, total_cents and 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.

Visible tests (5)
  • 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
Starter code
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) VALUES

More lines in Code Lab: 29

Try in Code Lab

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_api gains shop_rw's privileges, never the reverse.
  • REVOKE removes only a direct grant. If the same privilege still arrives through a group role or PUBLIC, the role keeps it, a classic surprise when cleaning up access.
  • PostgreSQL 15 stopped letting PUBLIC create objects in the public schema 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 SECURITY and CREATE 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 Lab

The 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.

Visible tests (6)
  • 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
Starter code
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

Try in Code Lab

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_connections and 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 Lab

An 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:

  1. a trigger AFTER DELETE ON accounts that inserts 'DELETE', the account id, the old balance, NULL as new balance and the current actor (in a DELETE trigger only OLD exists)
  2. two triggers, BEFORE UPDATE and BEFORE DELETE ON audit_log, that abort the statement with SELECT 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.

Visible tests (6)
  • 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
Starter code
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

Try in Code Lab

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.

Quick quiz

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, and REVOKE removes 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