Sensitive columns: what never goes in a table as plain text
Some columns announce what's inside them: password,
api_token, cpf, ssn, email.
That's useful twice, for the reviewer deciding how the value must be stored,
and unfortunately for the attacker reading a leaked dump. The decision per
column is always one of three: hash it, encrypt it, or store it plain and
control who sees it.
Hash: for values you only ever compare
A password is never displayed back; it's only checked. That's a hash, and specifically a slow, salted password hash, bcrypt, scrypt or Argon2, not SHA-256, which is built to be fast and therefore built to be brute-forced.
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(320) UNIQUE NOT NULL,
password_hash VARCHAR(100) NOT NULL -- bcrypt output, never the password
);
Name the column for what it holds (password_hash, not
password): the schema then documents the policy, and a plain
password VARCHAR in a code review is visibly wrong. The same
logic covers API keys you issue: store the hash, show the key once at
creation, compare hashes on use. Static lookup tokens can use a fast hash
(they're high-entropy, unlike passwords), but they still don't belong in the
table as-is.
Encrypt: for values you must read back
A document number, a bank account, an OAuth refresh token you'll replay, these must come back out, so they need encryption, not hashing. Do it in the application with a real AEAD scheme (libsodium, Tink, or your cloud KMS envelope encryption) and store the ciphertext:
refresh_token_enc BYTEA NOT NULL, -- AEAD ciphertext, key in the KMS
document_enc BYTEA
The property that matters: the key does not live in the database. Postgres'
pgcrypto can encrypt, but the key travels in every SQL statement,
so it shows up in logs, pg_stat_statements and crash dumps, and a
database-level compromise gets both ciphertext and key. Disk-level encryption
(EBS, LUKS) protects against a stolen disk and nothing else; a
SELECT still returns plaintext.
Encrypted columns can't be indexed for their content. If you need to look
users up by document, store a deterministic HMAC of the normalized value in a
separate indexed column (document_hmac) and search on that, the
pattern is called a blind index.
Plain, but handled: PII you query and display
Emails and names usually stay plaintext because the application reads and filters by them constantly. "Plain" still carries obligations: mask them in logs (the classic leak is not the database, it's the INFO log line with the whole request body), exclude them from analytics events, and know which tables hold PII when a deletion request arrives, LGPD and GDPR both mean "find every copy", and that inventory is much cheaper to keep than to reconstruct.
The schema tells on you
None of this needs the data to be visible. Column names and types alone,
password VARCHAR(50), token TEXT, cpf
VARCHAR(14) with no companion _enc/_hash
sibling, are enough to spot storage that's probably plaintext. A
VARCHAR(50) password column can't be holding a bcrypt hash (60
chars); the length gives it away. That's exactly the review a schema linter
can do early, when the fix is a migration on an empty table instead of a
re-encryption backfill on a full one.