Secure database engineering
What a stolen login can do
The idea in 30 seconds
Assume the application's database password leaks, or a query somewhere lets user input become SQL. What the attacker can then do is exactly what that login is allowed to do. So give each kind of work its own role with only the privileges it needs, never let the application log in as the owner of its tables, and keep input out of SQL text entirely: least privilege limits the damage, but only parameters prevent it.
Helps to know first: PostgreSQL security and multi-tenancy (coming soon), Functions, procedures and triggers, Connection management and pooling (coming soon)
On this page
Start from the breach
Security reviews usually ask “how could someone get in?”. For a database, a more useful first question is: if they did, what could they do? The application’s password ends up in logs, in a leaked config file, in a laptop backup. A single query somewhere builds SQL from user input. Either way, the attacker is now that login, with exactly its privileges.
So the first design decision is what each login is allowed to do.
One role per kind of work
| Role | Logs in? | Can | Used by |
|---|---|---|---|
shop_owner | No | Owns every table; can ALTER and DROP them | Migrations only, via SET ROLE or a separate migration login |
app | Yes | SELECT, INSERT, UPDATE, DELETE on the tables it uses | The application |
reporting | Yes | SELECT on what reports need | Dashboards, analysts |
| Superuser | Rarely | Everything, ignoring every privilege check | Administrators, break-glass only |
The distinction that matters most is between owning a table and being granted access to it. Only the owner (or a superuser) can drop or alter a table, and grants cannot take that away. An application that logs in as the owner can do anything to its schema that a migration can.
CREATE ROLE shop_owner NOLOGIN;
ALTER TABLE customers OWNER TO shop_owner; -- and every other table
CREATE ROLE app LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON customers, orders, order_items, products TO app;
-- tables created later by shop_owner get the same grants automatically
ALTER DEFAULT PRIVILEGES FOR ROLE shop_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
Without that last statement, every new table is invisible to the application until someone remembers to grant it, and the usual “fix” under pressure is to make the application the owner.
What a role gets without asking
A brand-new role with no grants at all is not powerless. Every role is a member of PUBLIC, which by default gets CONNECT and TEMPORARY on databases and EXECUTE on functions. We checked a fresh role on PostgreSQL 18.3: it could not read any table or create anything in public, but it could create temporary tables and execute 3,347 functions. Since PostgreSQL 15, CREATE on the public schema is no longer granted to everyone; on databases upgraded from older versions it may still be.
That matters for anything you write with SECURITY DEFINER, which runs with its owner’s privileges no matter who calls it. You will hijack one in the lab.
Least privilege limits damage; parameters prevent it
PredictSame injection, two logins
An endpoint looks a customer up by email and builds the query by gluing the input into the SQL text:
SELECT id, name FROM customers WHERE email = '<input>'An attacker submits two inputs: x'; DROP TABLE order_items; -- and then x' OR true --. What happens if the application logs in as the table owner, and what if it logs in as app with only SELECT, INSERT, UPDATE and DELETE?
We ran both, on PostgreSQL 18.3:
| Input | Logged in as the owner | Logged in as app |
|---|---|---|
x'; DROP TABLE order_items; -- | DROP TABLE: the table is gone | ERROR: must be owner of table order_items |
x' OR true -- | All 8 customers returned | All 8 customers returned |
Least privilege stopped the schema being destroyed. It did nothing for the data the application is allowed to read, which is usually the data an attacker wants. The same two inputs sent as parameters returned 0 rows each: the database received x' OR true -- as an email address to compare with, not as SQL.
The OWASP SQL injection guidance puts it in this order: parameterised queries first, least privilege as a second layer. Every mainstream driver supports parameters; in psql you can try them with \bind:
SELECT id, name FROM customers WHERE email = $1 \bind 'x'' OR true --' \g
SQL built inside the database
Injection is not only an application bug. A PL/pgSQL function that builds a statement as a string has exactly the same problem:
CREATE FUNCTION count_by_city(p_city text) RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE n bigint;
BEGIN
EXECUTE 'SELECT count(*) FROM customers WHERE city = ''' || p_city || '''' INTO n;
RETURN n;
END $$;
It only ever returns a number, so it looks harmless. We passed it a crafted city:
SELECT count_by_city($$x' AND 1 = CAST((SELECT string_agg(email, ',') FROM customers) AS int) AND 'a' = 'a$$);
ERROR: invalid input syntax for type integer: "asha@example.com,ben@example.com,chen@example.com,
divya@example.com,elena@example.com,farhan@example.com,grace@example.com,hugo@example.com"
Every email address in the table, delivered through the error message. Pass values with USING instead, and quote any identifiers with format('%I', ...):
EXECUTE 'SELECT count(*) FROM customers WHERE city = $1' INTO n USING p_city;
Getting in at all
Two settings decide who can even try a password:
- Password method. Use
scram-sha-256. MD5 password authentication is deprecated in PostgreSQL 18 and will be removed in a future version. - Network. In
pg_hba.conf, accept connections from other hosts only onhostssllines, so passwords and data never cross the network unencrypted, and only from the networks your application runs in.
Check yourself
The application logs in as
appwithSELECToncustomers. An injection is found. What can the attacker still do, and what can they not do?Show answer
They can read, and with the other grants change, any row the application could, including every customer’s data. They cannot drop or alter tables, because only the owner can do that.
Why does adding
ALTER DEFAULT PRIVILEGESmatter for keeping the application off the owner role?Show answer
Without it, each new table has no grants for the application, so it fails until someone grants access by hand. Under pressure, the easy fix is making the application the owner, and the separation is lost.
count_by_cityonly returns a number. How did it leak every email address?Show answer
The injected SQL cast all the emails to an integer, which failed, and PostgreSQL’s error message included the value it could not convert. The caller saw the error.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.OWASP Cheat Sheet Series: SQL Injection Prevention Cheat SheetReferenceOWASP· Primary Defenses: Defense Option 1; Additional Defenses: Least Privilege
Parameterised queries are the first defence against SQL injection; least privilege is an additional defence; application accounts should never have administrator access.
- 2.PostgreSQL documentation: 5.8 PrivilegesPrimary· §5.8 Privileges
Only the owner (or a superuser) can drop or alter an object; PUBLIC gets CONNECT and TEMPORARY on databases and EXECUTE on functions by default, and nothing on tables.
- 3.PostgreSQL documentation: 5.10 SchemasPrimary· §5.10.6 Usage Patterns
From PostgreSQL 15, the default configuration no longer lets every user create objects in the public schema.
- 4.PostgreSQL documentation: ALTER DEFAULT PRIVILEGESPrimary· Description
ALTER DEFAULT PRIVILEGES sets the privileges applied to objects created in the future, not to existing ones.
- 5.PostgreSQL documentation: 41.5.4 Executing Dynamic CommandsPrimary· §41.5.4 Executing Dynamic Commands
EXECUTE ... USING passes values as parameters; format() with %I and %L quotes identifiers and literals safely.
- 6.PostgreSQL documentation: E.4 Release 18Primary· Migration to Version 18: Deprecate MD5 password authentication
MD5 password authentication is deprecated in PostgreSQL 18 and will be removed in a future major version.
- 7.PostgreSQL documentation: 20.1 The pg_hba.conf FilePrimary· §20.1 The pg_hba.conf File: hostssl
hostssl records match only TCP/IP connections made with SSL encryption.
Go further
What to read next, and why it is worth the time.
- 1.OWASP Cheat Sheet Series: SQL Injection Prevention Cheat SheetReferenceOWASP
Short, practical and language-neutral. The order of its defences is the order to apply them in.
- 2.PostgreSQL documentation: 5.8 PrivilegesPrimary· §5.8, Table 5.2 Summary of Access Privileges
The full list of what can be granted on each kind of object, and what PUBLIC gets without asking.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.