Database Security
Protecting your data from threats, breaches, and violations.
Why Database Security is Non-Negotiable
SQL injection is how Albert Gonzalez got into Heartland Payment Systems in 2008 and walked out with roughly 130 million card numbers, still one of the largest payment-card breaches on record. The other two breaches everyone cites are worth getting right, because neither was a database flaw: Equifax lost data on about 147 million people in 2017 through an unpatched Apache Struts vulnerability (CVE-2017-5638, remote code execution), and Capital One's 2019 breach of roughly 106 million applicants started at a misconfigured web application firewall, where a server-side request forgery reached the EC2 instance metadata service and retrieved credentials for an over-privileged IAM role that could read the S3 buckets. Your database is only as safe as everything sitting in front of it. Database security isn't optional, it's the difference between a successful business and a catastrophic breach that destroys customer trust and triggers massive fines. Beyond attackers stealing credit cards or social security numbers, insecure databases face compliance violations (GDPR, HIPAA, SOC 2), data loss from ransomware, and privilege escalation where low-level users access sensitive data. This lesson covers SQL injection prevention (parameterized queries, ORMs), authentication & authorization (RBAC, row-level security), encryption (at rest with AES-256, in transit with TLS), audit logging for compliance, and secrets management (never hardcode passwords!). You'll learn the OWASP Top 10 risks that reach the database (injection is A05:2025, previously A03:2021), how to pass compliance audits.
Try it locally (PostgreSQL 16, Redis 8, MongoDB 8)
You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.
# Plain PostgreSQL 16 is all this lesson needs. docker run -d --name pg-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ -p 5432:5432 postgres:16-alpine docker exec -it pg-demo psql -U demo -d demo # Clean up: docker rm -f pg-demo # The lesson also touches Redis and MongoDB access control: docker run -d --name redis-demo -p 6379:6379 redis:8-alpine docker run -d --name mongo-demo -p 27017:27017 mongo:8 # Clean up: docker rm -f pg-demo redis-demo mongo-demo # The Python examples need a virtualenv: on Debian/Ubuntu a bare # "pip install" is refused (PEP 668: externally-managed-environment). python3 -m venv .venv && source .venv/bin/activate pip install "psycopg[binary]" bcrypt cryptography boto3 hvac
Then create the table the login examples authenticate against:
-- The login examples need a table to log in against. Run this in psql
-- before the Python blocks below, or they fail with
-- "relation \"users\" does not exist".
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT,
password_hash TEXT,
email TEXT
);
INSERT INTO users (username, password, email) VALUES
('admin', 'hunter2', 'admin@example.com'),
('alice', 'letmein', 'alice@example.com'),
('bob', 'password1', 'bob@example.com');
SELECT id, username, password, email FROM users ORDER BY id;Expected Output:
CREATE TABLE INSERT 0 3 ┌────┬──────────┬───────────┬───────────────────┐ │ id │ username │ password │ email │ ├────┼──────────┼───────────┼───────────────────┤ │ 1 │ admin │ hunter2 │ admin@example.com │ │ 2 │ alice │ letmein │ alice@example.com │ │ 3 │ bob │ password1 │ bob@example.com │ └────┴──────────┴───────────┴───────────────────┘ (3 rows)
That plaintext password column is deliberate, and it is the only reason the injection below is worth watching: it is the exact mistake the rest of this lesson dismantles. The password_hash column sits beside it unused for now, and the bcrypt section later fills it in properly.
SQL Injection: The #1 Database Vulnerability
SQL injection occurs when user input is concatenated directly into SQL queries, allowing attackers to execute arbitrary SQL commands. Injection topped the OWASP Top 10 from 2010 through 2017, then slipped to #3 (A03:2021) in the 2021 edition and #5 (A05:2025) in the 2025 edition, though it stays one of the categories with the most associated CVEs.
❌ Vulnerable Code (NEVER DO THIS!)
# DANGEROUS: User input directly in SQL string
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
# User provides: username = "admin' OR '1'='1"
username = input("Enter username: ")
password = input("Enter password: ")
# String concatenation = SQL INJECTION VULNERABILITY
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
cur.execute(query)
user = cur.fetchone()
if user:
print("Login successful!")
else:
print("Login failed")Input: username = "' OR '1'='1' -- "
password = "anything"
Generated SQL:
SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = 'anything'
Result (verified against the admin/alice/bob table seeded above):
fetchone() -> (1, 'admin', 'hunter2', None, 'admin@example.com')
Bypasses authentication! The -- comments out everything after it, including
the password check, so the query becomes "WHERE username = '' OR '1'='1'",
which matches every row, and fetchone() hands back the first one scanned,
admin's here. Dropping the comment does not save you either: a payload
like username = "admin' OR '1'='1", password = "wrong" ALSO logs in. AND
binds tighter than OR in SQL, so it parses as
username='admin' OR ('1'='1' AND password='wrong'), which reduces to
username='admin' OR password='wrong' - true the moment username='admin'
matches, regardless of the password (verified: it returns admin's row
with a deliberately wrong password). What actually blocks this payload is
not the missing comment, it is not knowing a username that exists: swap
"admin" for a nonexistent name and both sides of the OR go false.✅ Solution 1: Parameterized Queries (Prepared Statements)
# SECURE: Use parameterized queries
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
username = input("Enter username: ")
password = input("Enter password: ")
# Use %s placeholders and pass values separately
# Database driver escapes input automatically
cur.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password) # Tuple of parameters
)
user = cur.fetchone()
if user:
print("Login successful!")
else:
print("Login failed")"admin' OR '1'='1" is safely escaped as a username search. SQL injection prevented!✅ Solution 2: Use an ORM (SQLAlchemy)
# Install: pip install sqlalchemy
# ORMs automatically use parameterized queries
from sqlalchemy import create_engine, Column, String, Integer
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String)
password = Column(String)
# "postgresql://" alone defaults to the psycopg2 driver. This lesson
# installs psycopg 3, so name it: otherwise SQLAlchemy raises
# ModuleNotFoundError: No module named 'psycopg2'.
engine = create_engine('postgresql+psycopg://demo:demo@localhost/demo')
Session = sessionmaker(bind=engine)
session = Session()
# ORM query - automatically parameterized
username = input("Enter username: ")
password = input("Enter password: ")
user = session.query(User).filter(
User.username == username,
User.password == password
).first()
if user:
print(f"Login successful! Welcome {user.username}")
else:
print("Login failed")Other Injection Attack Vectors
- ORDER BY injection: Never use user input in ORDER BY (use whitelist:
allowed = ['name', 'date']) - LIMIT injection: Validate integers:
int(user_input)before using in LIMIT - Table/column names: Can't be parameterized, use strict whitelists and validation
- NoSQL injection: MongoDB, Redis also vulnerable (use parameterized queries in drivers)
Authentication & Authorization
Authentication = Who are you? Authorization = What can you do? Both are critical: authenticating users isn't enough if they can access data they shouldn't see.
Step 1: Never Store Plaintext Passwords
# Install: pip install bcrypt "psycopg[binary]"
import bcrypt
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
# REGISTRATION: Hash password before storing
def register_user(username, password):
# Generate salt and hash password
salt = bcrypt.gensalt(rounds=12) # 12 rounds = strong but not too slow
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
# Store username and password_hash. The plaintext password is never
# stored, and never leaves this function.
cur.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s)",
(username, password_hash.decode('utf-8'))
)
conn.commit()
print(f"User {username} registered successfully")
# LOGIN: Verify password against hash
def login_user(username, password):
cur.execute(
"SELECT password_hash FROM users WHERE username = %s",
(username,)
)
result = cur.fetchone()
# An unknown user and a user with no hash both land here. Treat them the
# same as a wrong password: never reveal which of the three it was.
if not result or result[0] is None:
print("Invalid credentials")
return False
if bcrypt.checkpw(password.encode('utf-8'), result[0].encode('utf-8')):
print("Login successful!")
return True
print("Invalid credentials")
return False
register_user("carol", "correct horse battery staple")
login_user("carol", "correct horse battery staple")
login_user("carol", "wrong password")
login_user("nobody", "whatever")
cur.execute("SELECT password_hash FROM users WHERE username = 'carol'")
print("\nStored value:", cur.fetchone()[0])Expected Output:
User carol registered successfully Login successful! Invalid credentials Invalid credentials Stored value: $2b$12$B5bamc25kjIZgCegChAThuKGt/2ibmjUfJufNu9EcoLa66FvEB7be
Read the stored value: $2b$ names the algorithm, 12 is the cost, and the next 22 characters are the salt, with the hash after it. The salt is stored in the open on purpose, because its job is not secrecy: it is to make sure two people who chose the same password get different hashes, which is what defeats precomputed rainbow tables. The cost is the other half. Each increment doubles the work, so 12 is roughly 4,000 times slower than an unsalted SHA-1, and that slowness is the feature. Note the three failure paths, wrong password, unknown user, and a user with no hash, all print the same thing: a login form that distinguishes them hands an attacker a way to enumerate valid usernames. Your run will show a different stored value than the one above, since a fresh salt is generated every time.
Step 2: Role-Based Access Control (RBAC)
-- Database schema for RBAC. "users" already exists from the setup above,
-- so this adds only the tables around it.
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
role_name VARCHAR(50) UNIQUE -- 'admin', 'editor', 'viewer'
);
CREATE TABLE user_roles (
user_id INT REFERENCES users(id),
role_id INT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
permission_name VARCHAR(100) -- 'read_posts', 'write_posts', 'delete_users'
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id),
permission_id INT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
-- Schema alone proves nothing: with no rows, every permission check
-- returns false and the next example can only demonstrate denial.
INSERT INTO roles (role_name) VALUES ('admin'), ('editor'), ('viewer');
INSERT INTO permissions (permission_name) VALUES
('read_posts'), ('write_posts'), ('delete_users');
-- admin gets everything, editor can read and write, viewer only reads.
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE (r.role_name = 'admin')
OR (r.role_name = 'editor' AND p.permission_name IN ('read_posts', 'write_posts'))
OR (r.role_name = 'viewer' AND p.permission_name = 'read_posts');
-- A spare account for the deletion demo further down.
INSERT INTO users (username, email) VALUES ('dave', 'dave@example.com');
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u, roles r
WHERE (u.username = 'admin' AND r.role_name = 'admin')
OR (u.username = 'alice' AND r.role_name = 'editor')
OR (u.username = 'bob' AND r.role_name = 'viewer');
SELECT u.username, r.role_name, p.permission_name
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions p ON p.id = rp.permission_id
ORDER BY u.username, p.permission_name;Expected Output:
┌──────────┬───────────┬─────────────────┐ │ username │ role_name │ permission_name │ ├──────────┼───────────┼─────────────────┤ │ admin │ admin │ delete_users │ │ admin │ admin │ read_posts │ │ admin │ admin │ write_posts │ │ alice │ editor │ read_posts │ │ alice │ editor │ write_posts │ │ bob │ viewer │ read_posts │ └──────────┴───────────┴─────────────────┘ (6 rows)
Users have roles, roles have permissions, and nothing links a user to a permission directly. That indirection is the whole point: granting every editor the right to publish is one row in role_permissions, not one row per editor and not a code change. The two join tables exist because both relationships are many-to-many, a user can hold several roles and a permission can belong to several roles, and the composite primary keys make a duplicate grant impossible rather than merely unlikely.
Step 3: Enforce RBAC in Application
from functools import wraps
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
def user_id_for(username):
cur.execute("SELECT id FROM users WHERE username = %s", (username,))
return cur.fetchone()[0]
# Check if user has permission
def has_permission(user_id, permission_name):
cur.execute("""
SELECT COUNT(*) FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = %s AND p.permission_name = %s
""", (user_id, permission_name))
count = cur.fetchone()[0]
return count > 0
# Decorator for permission-based access control
def require_permission(permission):
def decorator(func):
@wraps(func)
def wrapper(user_id, *args, **kwargs):
if not has_permission(user_id, permission):
raise PermissionError(f"User lacks '{permission}' permission")
return func(user_id, *args, **kwargs)
return wrapper
return decorator
@require_permission('delete_users')
def delete_user(user_id, target_user_id):
cur.execute("DELETE FROM users WHERE id = %s", (target_user_id,))
conn.commit()
print(f"User {target_user_id} deleted")
# Look the ids up rather than hardcoding them: they depend on how many
# times you have run the earlier blocks.
admin_id = user_id_for("admin")
alice_id = user_id_for("alice")
dave_id = user_id_for("dave")
print("admin can delete_users:", has_permission(admin_id, "delete_users"))
print("alice can delete_users:", has_permission(alice_id, "delete_users"))
print("alice can write_posts: ", has_permission(alice_id, "write_posts"))
print()
# The editor is refused before the DELETE is ever issued.
try:
delete_user(user_id=alice_id, target_user_id=dave_id)
except PermissionError as e:
print(f"Access denied: {e}")
# The admin is allowed through.
delete_user(user_id=admin_id, target_user_id=dave_id)Expected Output:
admin can delete_users: True alice can delete_users: False alice can write_posts: True Access denied: User lacks 'delete_users' permission User 5 deleted
Alice is an editor, so she holds write_posts but not delete_users, and the decorator raises before the function body runs: the DELETE is never sent to the database. Admin passes the same check and the row goes. The value of putting this in a decorator rather than an if at the top of each function is that the check cannot be forgotten silently, since a missing @require_permission is visible on the line above the function. Be clear about the limit, though: this is enforced in application code, so anything holding the same database credentials bypasses it entirely. That is exactly the gap row-level security closes next, by moving the rule into the database itself.
Step 4: Row-Level Security (PostgreSQL RLS)
-- Sample table and data (3 documents: 2 belong to user 42, 1 to user 7)
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
user_id INTEGER,
title TEXT
);
INSERT INTO documents (user_id, title) VALUES
(42, 'Alice: Q1 Report'),
(42, 'Alice: Budget Draft'),
(7, 'Bob: Meeting Notes');
-- Function to get current user ID from session.
-- Create this BEFORE the policies below: CREATE POLICY validates that
-- current_user_id() already exists, so defining the function first
-- avoids a "function does not exist" error.
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS INTEGER AS $$
BEGIN
RETURN current_setting('app.user_id')::INTEGER;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Enable Row-Level Security on table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Create policy: users can only see their own documents
CREATE POLICY user_documents ON documents
FOR SELECT
USING (user_id = current_user_id());
-- Create policy: users can only update their own documents
CREATE POLICY user_documents_update ON documents
FOR UPDATE
USING (user_id = current_user_id());
-- IMPORTANT: RLS is bypassed for superusers and for the table owner,
-- unless you also run "ALTER TABLE documents FORCE ROW LEVEL SECURITY".
-- Since the demo user created by the Docker command above is a superuser,
-- connecting as "demo" would see all 3 rows regardless of app.user_id.
-- Applications must connect as an ordinary, non-superuser role instead:
CREATE ROLE app_user WITH LOGIN PASSWORD 'app_password';
GRANT SELECT, UPDATE ON documents TO app_user;
GRANT USAGE, SELECT ON SEQUENCE documents_id_seq TO app_user;# Python application sets user context
import psycopg
# Connect as the dedicated non-superuser role, NOT as the "demo" superuser
conn = psycopg.connect("host=localhost dbname=demo user=app_user password=app_password")
cur = conn.cursor()
# Set current user for this session.
# Note: plain SET does not accept bind parameters ("SET app.user_id = %s"
# raises a syntax error). Use the set_config() function instead, which does.
user_id = 42
cur.execute("SELECT set_config('app.user_id', %s, false)", (str(user_id),))
# Query automatically filtered by RLS
cur.execute("SELECT * FROM documents")
results = cur.fetchall()
# Returns only documents where user_id = 42
# Even if query has no WHERE clause!
print(f"Found {len(results)} documents for user {user_id}")Expected Output:
Found 2 documents for user 42
Two rows out of three, and the query said SELECT * FROM documents with no WHERE clause at all. That is the difference between this and the RBAC decorator above it: the rule lives in the database, so forgetting the filter in application code costs you nothing. Bob's row was never a possibility. The critical caveat is who you connect as. PostgreSQL exempts superusers and table owners from RLS by default, so running this same script as demo prints 3, not 2, and the policy looks broken when it is actually being skipped. That is why the setup creates a dedicated app_user role, and why FORCE ROW LEVEL SECURITY exists for the case where the owner must be subject to its own policies too.
Encryption: At Rest & In Transit
Encryption protects data from attackers who gain physical access (stolen drives, cloud snapshots) or intercept network traffic (man-in-the-middle attacks).
Encryption at Rest (Database Files)
Transparent Data Encryption (TDE)
Database automatically encrypts data files with AES-256. No application changes needed.
MySQL: InnoDB encryption
SQL Server: TDE built-in
Application-Level Encryption
Encrypt sensitive fields before INSERT. More control but requires key management.
Library: cryptography (Python)
Algorithm: AES-256-GCM
# Install: pip install cryptography "psycopg[binary]"
# Application-level encryption with Fernet (AES-128)
import psycopg
from cryptography.fernet import Fernet
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
cur.execute("CREATE TABLE IF NOT EXISTS user_data (user_id INT, encrypted_ssn BYTEA)")
conn.commit()
# Generate encryption key (store in a secrets manager, NOT in code, and NOT
# regenerated per run: a new key makes every existing row undecryptable).
key = Fernet.generate_key()
cipher = Fernet(key)
# Encrypt sensitive data before storing
def store_sensitive_data(user_id, ssn):
encrypted_ssn = cipher.encrypt(ssn.encode())
cur.execute(
"INSERT INTO user_data (user_id, encrypted_ssn) VALUES (%s, %s)",
(user_id, encrypted_ssn)
)
conn.commit()
print("Data stored with encryption")
# Decrypt when retrieving
def get_sensitive_data(user_id):
cur.execute(
"SELECT encrypted_ssn FROM user_data WHERE user_id = %s",
(user_id,)
)
encrypted_ssn = cur.fetchone()[0]
decrypted_ssn = cipher.decrypt(encrypted_ssn).decode()
return decrypted_ssn
# Usage
store_sensitive_data(user_id=1, ssn="123-45-6789")
ssn = get_sensitive_data(user_id=1)
print(f"Retrieved SSN: {ssn}")
# What an attacker with a database dump actually gets:
cur.execute("SELECT encrypted_ssn FROM user_data WHERE user_id = 1")
print("On disk:", bytes(cur.fetchone()[0])[:40])Expected Output:
Data stored with encryption Retrieved SSN: 123-45-6789 On disk: b'gAAAAABqbfn0Lj66MBG_xqS2axIYal326iTr8qz_'
The last line is the point: a database dump yields gAAAAAB... and nothing else, because the value is encrypted before it ever reaches PostgreSQL. Both the ciphertext and the key differ on every run, so your output will not match the bytes above. That randomness is also the catch. This example calls Fernet.generate_key() at import time, which is fine for one run and fatal in production: restart the process and every row already stored becomes permanently unreadable. The key has to outlive the application, which is what the secrets-management section below is for. Be clear-eyed about what this buys, too. It protects a stolen dump or a stolen disk, and it does nothing against an attacker who already has your application's memory or its key.
Encryption in Transit (TLS/SSL)
The container from the top of this lesson runs with TLS off, which is worth seeing for yourself rather than taking on trust. Turn it on first:
# The pg-demo container from the top of this lesson ships with TLS off, so
# turn it on. A self-signed certificate is fine for a local demo and is
# exactly what a real deployment must NOT use (see below).
# The alpine image has no openssl binary, so add it, then generate a cert
# INSIDE the data directory where the postgres user already owns the files.
docker exec -u root pg-demo sh -c "apk add --no-cache openssl && \
openssl req -new -x509 -days 365 -nodes -text \
-out /var/lib/postgresql/data/server.crt \
-keyout /var/lib/postgresql/data/server.key \
-subj '/CN=localhost' && \
chmod 600 /var/lib/postgresql/data/server.key && \
chown postgres:postgres /var/lib/postgresql/data/server.key \
/var/lib/postgresql/data/server.crt"
# PostgreSQL reads the certificate at startup, so this needs a restart, not
# a reload. Get the cert wrong and the server refuses to come back up with
# "could not load server certificate file".
docker exec pg-demo psql -U demo -d demo -c "ALTER SYSTEM SET ssl = on;"
docker restart pg-demo
docker exec pg-demo psql -U demo -d demo -c "SHOW ssl;"Expected Output:
ssl ----- on (1 row)
Then connect, and ask the server what it actually negotiated:
# PostgreSQL connection with SSL/TLS
import psycopg
conn = psycopg.connect(
host="localhost",
port=5432,
dbname="demo",
user="demo",
password="demo",
sslmode="require", # Refuse to connect unless the link is encrypted
)
# Ask the server, not the client: pg_stat_ssl reports what this very
# backend negotiated.
with conn.cursor() as cur:
cur.execute("""
SELECT ssl, version, cipher
FROM pg_stat_ssl
WHERE pid = pg_backend_pid()
""")
ssl, version, cipher = cur.fetchone()
print(f"ssl={ssl} version={version} cipher={cipher}")
conn.close()
# sslmode="require" encrypts, but does NOT check who is on the other end.
# verify-full is what authenticates the server, and it rejects this
# self-signed certificate because no trusted CA vouches for it.
try:
psycopg.connect(
host="localhost", port=5432, dbname="demo",
user="demo", password="demo", sslmode="verify-full",
)
print("verify-full: connected")
except Exception as e:
print("verify-full rejected it:", str(e).strip().splitlines()[0])Expected Output:
ssl=True version=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384 verify-full rejected it: connection failed: connection to server at "127.0.0.1", port 5432 failed: root certificate file "/home/you/.postgresql/root.crt" does not exist
TLS 1.3 with AES-256-GCM, confirmed by the server rather than assumed by the client, which matters because sslmode is a request and pg_stat_ssl is the answer. The second half is the part most tutorials skip. require encrypts the connection but performs no check on who presented the certificate, so it stops passive eavesdropping and not an active man-in-the-middle: an attacker who can redirect your traffic simply presents their own certificate and require accepts it. verify-full is what closes that, and here it refuses precisely because a self-signed certificate has no trusted CA behind it. That refusal is the demo working. In production you point sslrootcert at the real CA bundle and use verify-full; the exact path in your error will name your own home directory.
Encryption Key Management Best Practices
- Never hardcode keys: Use environment variables or secrets managers (AWS KMS, HashiCorp Vault)
- Rotate keys regularly: Re-encrypt data with new keys every 90-365 days
- Separate key storage: Keys in different location than encrypted data
- Use key hierarchies: Master key encrypts data keys (envelope encryption)
Audit Logging & Compliance
Audit logs track who accessed what data and when. Required for compliance (GDPR, HIPAA, SOC 2) and forensic analysis after security incidents.
Step 1: Create Audit Log Table
-- Comprehensive audit log table
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT NOW(),
user_id INTEGER,
username VARCHAR(255),
action VARCHAR(50), -- 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'LOGIN'
table_name VARCHAR(100),
record_id INTEGER,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
success BOOLEAN
);
-- Index for fast queries
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_user ON audit_log(user_id);
CREATE INDEX idx_audit_table ON audit_log(table_name, record_id);Step 2: Implement Audit Logging
import json
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
def log_audit(user_id, username, action, table_name, record_id=None,
old_values=None, new_values=None, ip_address=None, success=True):
cur.execute("""
INSERT INTO audit_log
(user_id, username, action, table_name, record_id,
old_values, new_values, ip_address, success)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
user_id, username, action, table_name, record_id,
json.dumps(old_values) if old_values else None,
json.dumps(new_values) if new_values else None,
ip_address, success
))
conn.commit()
# Example: Log user data update
def update_user_email(user_id, new_email, current_user_id, ip_address):
# Get old value
cur.execute("SELECT email FROM users WHERE id = %s", (user_id,))
old_email = cur.fetchone()[0]
# Perform update
cur.execute(
"UPDATE users SET email = %s WHERE id = %s",
(new_email, user_id)
)
conn.commit()
# Log the change
log_audit(
user_id=current_user_id,
username="admin",
action="UPDATE",
table_name="users",
record_id=user_id,
old_values={"email": old_email},
new_values={"email": new_email},
ip_address=ip_address,
success=True
)
print("Email updated and logged")
# Usage. Look the id up: hardcoding one that does not exist makes
# fetchone() return None and the whole thing dies on a TypeError.
cur.execute("SELECT id FROM users WHERE username = 'alice'")
alice_id = cur.fetchone()[0]
update_user_email(user_id=alice_id, new_email="alice@newdomain.com",
current_user_id=1, ip_address="192.168.1.100")
cur.execute("""
SELECT username, action, table_name, record_id,
old_values, new_values, ip_address, success
FROM audit_log ORDER BY id DESC LIMIT 1
""")
row = cur.fetchone()
print("\nAudit row:")
for name, value in zip(
["username", "action", "table", "record_id", "old", "new", "ip", "success"], row
):
print(f" {name:<10} {value}")Expected Output:
Email updated and logged
Audit row:
username admin
action UPDATE
table users
record_id 2
old {'email': 'alice@example.com'}
new {'email': 'alice@newdomain.com'}
ip 192.168.1.100
success TrueThe row records who acted, which record moved, and both sides of the change, which is what makes an audit trail answerable rather than merely present: "alice's email changed" is not an audit, "admin changed it from this to that, from that address" is. Note the old value has to be read before the update, since afterwards it is gone. Two details are doing quiet work here. old_values and new_values are JSONB, so one audit table serves every table in the schema without a column per field, and psycopg hands them back as dicts. ip_address is INET rather than text, so PostgreSQL validates the address on the way in and can answer subnet queries later. The weakness is structural, though: this logging lives in application code, so any path that updates users without calling log_audit leaves no trace at all. The trigger approach below closes that gap by moving the logging into the database.
Step 3: Automatic Audit Logging with Triggers
-- Trigger function to log all changes
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (action, table_name, record_id, new_values)
VALUES ('INSERT', TG_TABLE_NAME, NEW.id, row_to_json(NEW));
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (action, table_name, record_id, old_values, new_values)
VALUES ('UPDATE', TG_TABLE_NAME, NEW.id, row_to_json(OLD), row_to_json(NEW));
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (action, table_name, record_id, old_values)
VALUES ('DELETE', TG_TABLE_NAME, OLD.id, row_to_json(OLD));
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
-- The function is table-agnostic: TG_TABLE_NAME and row_to_json() mean it
-- works on anything with an "id" column. A second table shows that off,
-- and nothing earlier in this lesson created one.
CREATE TABLE transactions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
amount NUMERIC(10,2)
);
-- Attach trigger to sensitive tables
CREATE TRIGGER audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
CREATE TRIGGER audit_transactions
AFTER INSERT OR UPDATE OR DELETE ON transactions
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
-- Three ordinary statements. No application code calls log_audit here.
INSERT INTO transactions (user_id, amount) VALUES (2, 99.50);
UPDATE transactions SET amount = 120.00 WHERE id = 1;
DELETE FROM transactions WHERE id = 1;
SELECT action, table_name, record_id,
old_values->>'amount' AS old_amount,
new_values->>'amount' AS new_amount
FROM audit_log
WHERE table_name = 'transactions'
ORDER BY id;Expected Output:
┌────────┬──────────────┬───────────┬────────────┬────────────┐ │ action │ table_name │ record_id │ old_amount │ new_amount │ ├────────┼──────────────┼───────────┼────────────┼────────────┤ │ INSERT │ transactions │ 1 │ │ 99.50 │ │ UPDATE │ transactions │ 1 │ 99.50 │ 120.00 │ │ DELETE │ transactions │ 1 │ 120.00 │ │ └────────┴──────────────┴───────────┴────────────┴────────────┘ (3 rows)
Three plain SQL statements, three audit rows, and not one call to log_audit. That is the difference from the Python version above: the logging is attached to the table, so it fires for psql, for a migration script, for an ORM, and for the colleague who connects with a GUI client at midnight. Notice the same function serves both tables, because TG_TABLE_NAMEand row_to_json(NEW) never name a column: add a third table and it needs a trigger, not new code. Two limits are worth stating plainly. The function assumes an id column, so a table with a composite or differently named key needs its own variant. And "can't be bypassed" is too strong: anyone who can ALTER TABLE ... DISABLE TRIGGER or drop the function can silence it, which is why the audit table and the trigger should be owned by a role your application does not have.
Compliance Requirements (GDPR, HIPAA, SOC 2)
| Standard | Key Requirements | Database Controls |
|---|---|---|
| GDPR (EU Privacy) | • Right to be forgotten • Data portability • Breach notification (72hr) • Consent tracking | • Audit logs (who accessed PII) • Data deletion procedures • Encryption at rest • Export capabilities |
| HIPAA (US Healthcare) | • PHI protection • Access controls • Audit trails • Encryption required | • RBAC for patient data • Audit log retention (6 years) • TLS for all connections • Automatic session timeout |
| SOC 2 (Security/Privacy) | • Access reviews • Change management • Incident response • Monitoring | • Quarterly access audits • Database change logs • Anomaly detection alerts • Security patch management |
GDPR Example: Right to be Forgotten
# Complete user data deletion (GDPR compliance).
# Reuses log_audit() and the conn/cur from the audit-logging block above.
import json
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()
def log_audit(user_id, username, action, table_name, record_id=None,
old_values=None, new_values=None, ip_address=None, success=True):
cur.execute("""
INSERT INTO audit_log
(user_id, username, action, table_name, record_id,
old_values, new_values, ip_address, success)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
user_id, username, action, table_name, record_id,
json.dumps(old_values) if old_values else None,
json.dumps(new_values) if new_values else None,
ip_address, success
))
conn.commit()
# Tables that reference users(id), in the order they must be cleared:
# children first, or the foreign keys reject the delete.
CHILD_TABLES = ["user_roles", "transactions"]
def gdpr_delete_user(user_id, admin_user_id, reason):
# Log the request BEFORE deleting, with success=False. If the delete
# fails halfway, the request itself is still on record.
log_audit(
user_id=admin_user_id,
username="admin",
action="GDPR_DELETE",
table_name="users",
record_id=user_id,
new_values={"reason": reason},
success=False
)
for table in CHILD_TABLES:
# The table name is interpolated because it cannot be a bind
# parameter. It is safe ONLY because CHILD_TABLES is a fixed list
# in this file; never build this from user input.
cur.execute(f"DELETE FROM {table} WHERE user_id = %s", (user_id,))
print(f"Deleted {cur.rowcount} row(s) from {table}")
# users is keyed by "id", not "user_id": the same WHERE clause does not
# work on the parent table.
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
print(f"Deleted {cur.rowcount} row(s) from users")
conn.commit()
# Update audit log to mark success
log_audit(
user_id=admin_user_id,
username="admin",
action="GDPR_DELETE",
table_name="users",
record_id=user_id,
new_values={"reason": reason},
success=True
)
print(f"User {user_id} completely deleted (GDPR compliant)")
# Build a user with related rows so the deletion has something to clear.
cur.execute("INSERT INTO users (username, email) VALUES ('erin', 'erin@example.com') RETURNING id")
erin_id = cur.fetchone()[0]
cur.execute("INSERT INTO transactions (user_id, amount) VALUES (%s, 42.00)", (erin_id,))
cur.execute("INSERT INTO user_roles (user_id, role_id) SELECT %s, id FROM roles WHERE role_name = 'viewer'", (erin_id,))
conn.commit()
gdpr_delete_user(erin_id, admin_user_id=1, reason="User requested deletion via GDPR form")
cur.execute("SELECT action, record_id, success FROM audit_log WHERE action = 'GDPR_DELETE' ORDER BY id")
print("\nAudit trail:")
for action, record_id, success in cur.fetchall():
print(f" {action} record_id={record_id} success={success}")Expected Output:
Deleted 1 row(s) from user_roles Deleted 1 row(s) from transactions Deleted 1 row(s) from users User 6 completely deleted (GDPR compliant) Audit trail: GDPR_DELETE record_id=6 success=False GDPR_DELETE record_id=6 success=True
Order is the whole difficulty. Children go first, because transactions.user_id and user_roles.user_id both reference users(id) and PostgreSQL will refuse the parent delete while either still points at it. The parent then needs a different predicate: users is keyed by id, so a loop that applies WHERE user_id = %s uniformly fails on the one table that matters most. The two audit rows, success=False then success=True, are deliberate: the request is recorded before any row is touched, so a deletion that dies halfway still leaves evidence that it was asked for. Two honest caveats. This list is maintained by hand, so a table added later and not added here is exactly how "deleted" users survive; a query against information_schema for foreign keys referencing users, or ON DELETE CASCADE on the constraints themselves, removes that class of mistake. And the audit row deliberately outlives the user, which is the correct reading of GDPR rather than a violation of it: you must keep a record that the erasure happened.
Secrets Management
Database credentials, API keys, and encryption keys must NEVER be hardcoded or committed to version control. Use secrets managers and environment variables.
❌ What NOT to Do
Common Mistakes (DON'T DO THESE!)
1. Hardcoded credentials in code:
# DANGEROUS!
conn = psycopg.connect(
host="prod-db.company.com",
user="postgres",
password="SuperSecret123!" # ← Will be in Git history forever!
)2. Credentials in config files committed to Git:
# config.json (committed to repo)
{
"db_password": "prod_password_123" # ← Exposed to all devs
}3. Credentials in environment variables on laptops:
Better than hardcoding, but still risky if laptop is compromised✅ Solution 1: Environment Variables (Basic)
# .env file (add to .gitignore!)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_USER=postgres
DB_PASSWORD=secure_password_here
import os
import psycopg
from dotenv import load_dotenv
load_dotenv()
conn = psycopg.connect(
host=os.getenv("DB_HOST"),
port=os.getenv("DB_PORT"),
dbname=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD")
)
print("Connected using environment variables")✅ Solution 2: AWS Secrets Manager (Production)
# Install: pip install boto3 "psycopg[binary]"
import json
import boto3
import psycopg
from botocore.exceptions import ClientError
def get_secret(secret_name, region_name="us-east-1"):
# Create a Secrets Manager client
session = boto3.session.Session()
client = session.client(
service_name='secretsmanager',
region_name=region_name
)
try:
response = client.get_secret_value(SecretId=secret_name)
except ClientError as e:
# Name the failure. A missing secret and a missing IAM permission
# are different problems and the error code distinguishes them.
code = e.response['Error']['Code']
raise RuntimeError(f"Could not read secret {secret_name!r}: {code}") from e
# Secrets Manager returns SecretString or SecretBinary. RDS-style
# credential secrets are JSON in SecretString.
return json.loads(response['SecretString'])
# Fetch at connection time, not once at import: rotation is the entire
# reason to use a secrets manager, and a value cached for the process
# lifetime keeps working until the old credential is revoked, then fails.
db_secret = get_secret("prod/database/credentials")
conn = psycopg.connect(
host=db_secret['host'],
port=db_secret['port'],
dbname=db_secret['dbname'],
user=db_secret['username'],
password=db_secret['password']
)
print("Connected using AWS Secrets Manager")✅ Solution 3: HashiCorp Vault (Enterprise)
# Install: pip install hvac "psycopg[binary]"
import hvac
import psycopg
# Authenticate to Vault. AppRole is the machine-to-machine method: the
# role_id identifies the application, the secret_id is the credential, and
# both come from the environment rather than being written here.
client = hvac.Client(url='https://vault.company.com')
client.auth.approle.login(
role_id='your-role-id',
secret_id='your-secret-id'
)
# Read database credentials. Set raise_on_deleted_version explicitly:
# leaving it out warns today and silently flips from True to False in
# hvac 3.0.0, which would turn a deleted secret into a soft failure.
secret = client.secrets.kv.v2.read_secret_version(
path='database/postgres/prod',
raise_on_deleted_version=True
)
db_creds = secret['data']['data']
conn = psycopg.connect(
host=db_creds['host'],
user=db_creds['username'],
password=db_creds['password'],
dbname=db_creds['dbname']
)
print("Connected using HashiCorp Vault")Secrets Management Best Practices
- Never commit secrets to Git: Use .gitignore for .env files, scan repos with tools like git-secrets
- Rotate credentials regularly: Database passwords every 90 days, API keys every 6-12 months
- Use different credentials per environment: Dev, staging, prod should never share passwords
- Least privilege: Application should have minimum permissions needed (not superuser!)
- Audit access: Log who retrieved secrets and when
Database Security Checklist
✅ Application Security
- Use parameterized queries (no SQL injection)
- Hash passwords with bcrypt/scrypt
- Implement RBAC/permissions
- Validate all user input
- Use ORMs when possible
- Implement rate limiting
✅ Database Configuration
- Enable TLS/SSL for connections
- Disable remote root login
- Configure firewall rules (whitelist IPs)
- Enable audit logging
- Set up automated backups
- Apply security patches promptly
✅ Access Control
- Principle of least privilege
- Separate users per application/service
- Revoke unused accounts
- Implement row-level security
- Multi-factor authentication for admins
- Quarterly access reviews
✅ Monitoring & Response
- Monitor failed login attempts
- Alert on unusual query patterns
- Track privilege escalations
- Log all schema changes
- Test incident response plan
- Regular security assessments
Key Takeaways
- SQL injection is preventable: Always use parameterized queries or ORMs. Never concatenate user input into SQL strings.
- Defense in depth: Combine application security (RBAC, input validation) with database security (row-level security, encryption).
- Encrypt everything: Data at rest (TDE or app-level), data in transit (TLS), and backups.
- Audit trails are mandatory: Log all access to sensitive data for compliance (GDPR, HIPAA) and incident response.
- Secrets management is critical: Never hardcode credentials. Use secrets managers (AWS Secrets Manager, HashiCorp Vault) in production.