Skip to content
EL
Contents

Secure database engineering

What a stolen login can do

ConceptExpert6 min read15 min hands-on#security#sql

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

RoleLogs in?CanUsed by
shop_ownerNoOwns every table; can ALTER and DROP themMigrations only, via SET ROLE or a separate migration login
appYesSELECT, INSERT, UPDATE, DELETE on the tables it usesThe application
reportingYesSELECT on what reports needDashboards, analysts
SuperuserRarelyEverything, ignoring every privilege checkAdministrators, 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?

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 on hostssl lines, so passwords and data never cross the network unencrypted, and only from the networks your application runs in.

Check yourself

  1. The application logs in as app with SELECT on customers. 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.

  2. Why does adding ALTER DEFAULT PRIVILEGES matter 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.

  3. count_by_city only 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. 1.
    OWASP Cheat Sheet Series: SQL Injection Prevention Cheat Sheet
    ReferenceOWASP· 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. 2.
    PostgreSQL documentation: 5.8 Privileges
    Primary· §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. 3.
    PostgreSQL documentation: 5.10 Schemas
    Primary· §5.10.6 Usage Patterns

    From PostgreSQL 15, the default configuration no longer lets every user create objects in the public schema.

  4. 4.
    PostgreSQL documentation: ALTER DEFAULT PRIVILEGES
    Primary· Description

    ALTER DEFAULT PRIVILEGES sets the privileges applied to objects created in the future, not to existing ones.

  5. 5.
    PostgreSQL documentation: 41.5.4 Executing Dynamic Commands
    Primary· §41.5.4 Executing Dynamic Commands

    EXECUTE ... USING passes values as parameters; format() with %I and %L quotes identifiers and literals safely.

  6. 6.
    PostgreSQL documentation: E.4 Release 18
    Primary· 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. 7.
    PostgreSQL documentation: 20.1 The pg_hba.conf File
    Primary· §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. 1.
    OWASP Cheat Sheet Series: SQL Injection Prevention Cheat Sheet
    ReferenceOWASP

    Short, practical and language-neutral. The order of its defences is the order to apply them in.

  2. 2.
    PostgreSQL documentation: 5.8 Privileges
    Primary· §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.