Chapter 48 — Database security in depth
In one sentence
Beyond row-level security and encryption, a production database needs its own hardening layer — masking for non-privileged viewers, activity monitoring for bulk exfiltration, IAM-based short-lived authentication instead of static passwords, and the basic hygiene that a recurring wave of mass breaches shows teams still skip.
Where we are
Part 5 Ch 27 gave you row-level security for multi-tenancy; Ch 42 gave you field-level encryption. This chapter rounds out database hardening with the controls that catch what those two don't: a legitimately-authorized user querying too much, an exposed instance with no auth at all, and static credentials that outlive their purpose.
How it works
Dynamic data masking — controlling what's seen, not what's stored:
Different from encryption: the underlying data is stored normally
(encrypted at rest per Ch 42 if warranted), but the DATABASE ENGINE
masks it AT QUERY TIME based on the caller's role -- a support analyst
role sees `j***.d**@***.com` where an authorized service sees the real
value, with NO application code changes needed for the masking itself.
Available natively in SQL Server (Dynamic Data Masking), Snowflake
(masking policies), and via extensions/proxies for Postgres/MySQL.
USE FOR: giving broad internal visibility (support, debugging,
analytics dashboards) without broad DATA EXPOSURE -- a middle ground
between "full access" and "no access", cheaper to operate than
building a bespoke masking API for every internal tool.
NOT A SUBSTITUTE for Ch 42's field-level encryption where the threat
model includes a privileged/root DB account or the DBA role itself --
masking is enforced BY the engine, so an account with masking-bypass
privilege (often granted broadly by default) sees everything.
Database Activity Monitoring (DAM) — catching bulk exfiltration:
The threat masking and RLS don't catch: a LEGITIMATELY authorized
account (a compromised support engineer's credentials, an insider)
running `SELECT * FROM users` with no WHERE clause and exporting the
result -- every individual row access was "authorized"; the PATTERN is
the anomaly.
DAM tools (Imperva, IBM Guardium, or NATIVE options: AWS RDS/Aurora
Activity Streams, Google Cloud SQL audit logs, PostgreSQL's pgAudit
extension) watch and can ALERT on:
- queries returning unusually large result sets for that role/user's
normal pattern.
- a `SELECT *` / broad export pattern from an account that normally
does narrow, parameterised lookups.
- access outside normal hours / from a new source IP for that
credential.
- schema/permission changes outside a change window.
Pair with QUERY RESULT SIZE CAPS and paginated-by-default APIs at the
application layer (G1 Ch 45/this guide's Ch 28) as a structural
backstop, not just a detection rule.
Authentication — IAM-based over static passwords:
STATIC DB PASSWORDS (even rotated ones, G1 Ch 49) are a standing
secret that can leak, get committed, or get shared between environments.
IAM DATABASE AUTHENTICATION (AWS RDS/Aurora IAM auth, GCP Cloud SQL
IAM auth, Azure AD authentication for SQL) issues SHORT-LIVED AUTH
TOKENS derived from the calling workload's IAM/cloud identity
(Part 2 Ch 5, Part 4 Ch 20's workload identity) instead of a
password -- the same short-lived-credential principle applied to the
database connection itself. No password to leak, rotate, or store;
access is governed by the SAME IAM policy graph as everything else
(Part 2 Ch 6), so revoking a compromised workload's IAM access also
revokes its DB access immediately.
Where IAM auth isn't available: DYNAMIC SECRETS from a vault (G1 Ch 49)
-- a short-lived DB credential minted per session and auto-expired --
is the next-best option, strictly better than a long-lived static
password.
Least privilege and account hygiene:
[ ] a SEPARATE DB account per SERVICE (not one shared "app" account
for everything) -- so a compromised service's blast radius is
exactly its own grants, and query-pattern anomalies (DAM, above)
are attributable to a specific service, not "the app" generically.
[ ] NO service account is a superuser, the table owner (unless FORCE
RLS is relied on, Part 5 Ch 27), or has BYPASSRLS.
[ ] STORED PROCEDURES built with dynamic SQL are just as
injection-prone as application code (G1 Ch 42) -- parameterise
inside procedures too; a proc that concatenates a caller-supplied
string isn't safer for being server-side.
[ ] REVIEW extensions/plugins: some (Postgres `dblink`, untrusted
procedural languages) can be privilege-escalation vectors if
available to non-superuser roles -- audit what's installed and
who can invoke it.
[ ] PATCH the engine on a defined cadence; database CVEs are as real
as application CVEs (G1 Ch 47) and often more severe given the
blast radius.
The exposed-database problem — still a leading mass-breach cause:
A recurring, entirely preventable pattern: a MongoDB/Elasticsearch/
Redis instance bound to 0.0.0.0 and exposed directly to the internet
with NO AUTHENTICATION -- automated scanners (Shodan-driven) find and
exfiltrate/ransom these within HOURS of exposure. This traces back to
defaults: pre-2.6 MongoDB shipped with NO auth enabled by default,
causing a wave of mass exposures that continues to recur as new
instances get spun up from old images/tutorials/defaults.
THE FIX IS BASIC AND NON-NEGOTIABLE: authentication ALWAYS enabled
(never rely on "it's only reachable internally" as the sole control --
G1 Ch 39/this guide's Part 2 Ch 8's network layer is defence IN DEPTH,
not a substitute); bind to private interfaces only; use the engine's
real access-control layer (Elasticsearch/OpenSearch security
plugin/X-Pack, Redis AUTH + ACLs since Redis 6, MongoDB's role-based
access control) -- and CONTINUOUSLY SCAN your own external attack
surface (an internal Shodan-equivalent check, or your cloud's exposed-
resource finder) so you find an accidental exposure before an
attacker's scanner does.
Worked example
Catching a slow-motion insider exfiltration that RLS and encryption both missed.
A support engineer's LEGITIMATE role (needed for ticket resolution)
has broad SELECT on the `users` and `orders` tables -- correctly
scoped by RLS to be read-only and (per Ch 42) field-encrypted PII
returns as ciphertext for THIS role specifically (only a narrower
"support-lookup" service role, invoked per-ticket with an authorization
check, ever gets plaintext -- Ch 42's worked example, applied).
Over three weeks, the engineer's credentials (phished, unknown to
anyone yet) are used to run a SLOWLY INCREASING number of broad,
unparameterised queries -- staying just under any single-query size
cap, but climbing well above this account's historical baseline
volume.
RLS: not violated (queries stay within the account's own tenant scope
in this case -- it's a single-tenant internal support account with
legitimately broad internal access). Field encryption: not
violated (ciphertext returned for encrypted fields, as designed --
this exfiltration is of NON-field-encrypted metadata: order IDs,
timestamps, product categories, city-level location -- still
commercially sensitive and still personal data, just not the specific
fields chosen for field-level encryption).
WHAT ACTUALLY CATCHES IT: Database Activity Monitoring, comparing this
account's DAILY QUERY VOLUME and DISTINCT-ROW-COUNT against its
90-day baseline -- flags a gradual, sustained deviation that no
single-query threshold would trip. The alert routes to the detection
pipeline (Ch 54) with the account, the query pattern, and the volume
trend attached.
LESSON: RLS and field encryption are necessary but answer different
questions ("is this query scoped to the right tenant" and "is this
specific field protected from broad access") than DAM answers ("is
this account's OVERALL BEHAVIOUR consistent with its normal, legitimate
use") -- you need all three layers, because each one is blind to what
the others catch.
Practice (75 min)
- Set up pgAudit (or your cloud's native DB audit logging) on a Postgres
instance; run a mix of narrow and broad (
SELECT *, noLIMIT) queries; confirm the audit log distinguishes them with enough detail to build a volume/pattern baseline. - Configure dynamic data masking (or a proxy-based equivalent) for one column; confirm a "support" role sees masked values while an authorized service role sees real ones, with zero application code changes for the masking itself.
- Migrate one service's DB connection from a static password to IAM database authentication (or a Vault dynamic secret if IAM auth isn't available for your engine); confirm revoking the workload's IAM role/policy immediately breaks its DB access.
- Audit a real (or sandbox) database for: shared service accounts, superuser/ BYPASSRLS grants on app roles, installed extensions with elevated capability, and any instance reachable without authentication. Fix what you find.
- Run an external exposure scan (your cloud's public-resource finder, or a Shodan search for your own IP ranges) and confirm no database is internet-reachable without your knowledge.
Common mistakes
- Treating RLS or encryption as covering "database security" entirely. Neither catches a legitimately-scoped account behaving anomalously at volume — that needs activity monitoring.
- A shared "app" database account across services. Erases attribution and maximises blast radius; one account per service.
- Static, long-lived DB passwords, especially shared across environments. Move to IAM auth or dynamic secrets.
- Assuming "it's on a private subnet" is sufficient auth. Defence in depth means the database's OWN authentication must always be on, regardless of network placement — the exposed-database breach wave happened to instances people also assumed were "internal only."
- Dynamic SQL inside stored procedures. Just as injectable as application code with string concatenation.
- Never auditing installed extensions/plugins. Some carry privilege- escalation risk if reachable by non-superuser roles.
- No baseline for "normal" query volume/pattern per account. Without one, DAM has nothing to compare against and produces noise instead of signal.
Check yourself
- How does dynamic data masking differ from field-level encryption, and what threat does masking NOT cover that encryption does?
- What does Database Activity Monitoring catch that row-level security and encryption both miss?
- Why is IAM database authentication preferable to static passwords, and what happens to DB access when you revoke the underlying IAM identity?
- Give four items on the least-privilege/account-hygiene checklist for databases.
- What historical default caused a wave of exposed-database mass breaches, and what's the non-negotiable fix?
- In the worked example, why did neither RLS nor field encryption catch the exfiltration, and what did?
Further reading
- Docs: PostgreSQL
pgAuditextension documentation; AWS "RDS/Aurora Database Activity Streams"; Snowflake and SQL Server dynamic-masking documentation. - IAM auth: AWS "IAM database authentication for MariaDB, MySQL, and PostgreSQL"; GCP "Cloud SQL IAM database authentication".
- History: the "MongoDB ransom" wave write-ups (2017 and recurring) and Shodan's own blog posts on exposed-database scanning — sobering and educational on how fast unauthenticated instances are found.
- Standard: CIS Benchmarks for PostgreSQL/MySQL/MongoDB — a concrete hardening checklist to run against any instance you operate.