Identity, Authentication, and Access Control
Database security starts with answering three different questions:
- Who or what is connecting? β authentication.
- What are they allowed to do? β authorization.
- Can the action be traced back to that identity? β accountability.
A secure design keeps those questions separate. A strong password does not compensate for excessive privileges, and least privilege is difficult to audit if every service shares one database account.
Identity model
A common application path is:
human / service / job
β
βΌ
application identity
β
βΌ
database login / workload identity
β
βΌ
roles + grants
β
βΌ
schema / table / function permissions
The database identity should represent a meaningful security boundary.
Examples:
app_apifor the public API,reporting_readerfor dashboards,migration_runnerfor schema changes,backup_operatorfor backups,- named administrator identities for humans.
Avoid one shared superuser for every workload.
Authentication versus authorization
Authentication proves identity.
Examples:
- password,
- client certificate,
- Kerberos,
- cloud IAM token,
- managed workload identity.
Authorization decides permissions after authentication.
Examples:
GRANT SELECT ON reporting.daily_sales TO analyst;
GRANT INSERT, UPDATE ON app.orders TO app_writer;
An authenticated account can still be intentionally denied access to most data.
Least privilege
Least privilege means granting only what a workload needs to perform its job.
Suppose an API needs to:
- read products,
- create orders,
- update order status.
It does not need:
DROP TABLE,- role creation,
- backup administration,
- access to payroll tables.
A good privilege boundary reduces the damage from:
- SQL injection,
- leaked application credentials,
- programming mistakes,
- compromised hosts.
application compromised
β
βΌ
database account permissions
β
βββ broad superuser -> broad damage
βββ narrow role -> limited blast radius
PostgreSQL role model
PostgreSQL roles can act as:
- login identities,
- permission groups,
- both.
A useful pattern separates login roles from group roles.
login: shop_api
β
βββ member of app_read
βββ member of app_write
The group roles own permissions; the login gets membership.
Local setup
This repository already includes a local PostgreSQL environment:
cd scripts
bash setup/start_postgres.sh
The development connection is:
postgresql://demo:secret@127.0.0.1:5432/test
The credentials are intentionally simple for local learning. They are not a production credential design.
Runnable least-privilege demo
The repository includes:
scripts/security/postgres_least_privilege.sql
Run it inside the local PostgreSQL container:
docker exec -i postgres-local psql -U demo -d test < scripts/security/postgres_least_privilege.sql
The script creates:
- a dedicated schema,
- a sample table,
- read/write group roles,
- login roles with different memberships,
- default privilege rules for future tables.
It also prints grants so the resulting permission model is visible.
Create group roles
A group role normally does not log in:
CREATE ROLE app_read NOLOGIN;
CREATE ROLE app_write NOLOGIN;
Grant object permissions:
GRANT USAGE ON SCHEMA secure_app TO app_read, app_write;
GRANT SELECT
ON ALL TABLES IN SCHEMA secure_app
TO app_read;
GRANT INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA secure_app
TO app_write;
The separation makes permission changes easier:
objects -> group role -> login role
rather than granting every table individually to every account.
Login roles
Create workload identities:
CREATE ROLE reporting_login
LOGIN
PASSWORD 'local-demo-only';
CREATE ROLE api_login
LOGIN
PASSWORD 'local-demo-only';
Assign memberships:
GRANT app_read TO reporting_login;
GRANT app_read, app_write
TO api_login;
The reporting account can read.
The API account can read and write.
Neither role needs superuser privileges.
Ownership matters
The owner of an object has special rights over it.
A common production pattern is:
schema owner / migration role
β
βββ owns tables
β
βββ does not serve normal traffic
application role
β
βββ receives only runtime grants
Do not make the application login the owner of every table if it does not need DDL rights.
Default privileges
Grants on existing tables do not automatically cover every future table.
PostgreSQL supports default privileges:
ALTER DEFAULT PRIVILEGES
IN SCHEMA secure_app
GRANT SELECT ON TABLES TO app_read;
This matters when migrations continuously create new objects.
Without a default-grant strategy, a new table may be:
- accidentally inaccessible,
- accidentally exposed through an overly broad owner account.
Public privileges
Review the implicit PUBLIC role.
PUBLIC means every database role.
For sensitive schemas, explicitly design whether general users should have:
- database
CONNECT, - schema
USAGE, - function execution.
Do not assume defaults match the application's security model.
Schema isolation
Schemas can provide administrative separation:
database
βββ app
βββ reporting
βββ internal
βββ audit
A reporting user may have access only to:
reporting.*
Schema permissions do not replace table permissions, but they add another boundary.
Read-only is more than SELECT
A "read-only" role should be reviewed for:
- table writes,
- sequence changes,
- function execution,
- temporary object creation,
- access to sensitive system functions.
Functions can execute with the caller's rights or, in some systems, with definer rights.
A role that cannot directly update a table might still call a powerful function.
Security definer functions
PostgreSQL supports SECURITY DEFINER functions.
They execute with the function owner's privileges.
This can be useful for narrowly controlled operations:
low-privilege caller
β
βΌ
approved function
β
βΌ
specific privileged action
But a poorly written definer function becomes a privilege-escalation path.
Important defenses include:
- fixed/controlled
search_path, - parameterized SQL,
- minimal owner privileges,
- narrow
EXECUTEgrants, - code review.
Row-level security
Sometimes table-level grants are not enough.
Suppose all tenants share one table:
orders
βββ tenant_id
βββ order_id
βββ total
The application should only see rows for its tenant.
PostgreSQL Row-Level Security (RLS) can enforce row policies inside the database.
Conceptually:
SELECT * FROM orders
β
βΌ
RLS policy
β
βββ tenant matches -> row visible
βββ tenant differs -> hidden
Example input (orders):
| tenant_id | order_id | total |
|---|---|---|
| 42 | 1001 | 49.90 |
| 73 | 1002 | 29.00 |
With the RLS policy set to tenant 42, the query returns:
| tenant_id | order_id | total |
|---|---|---|
| 42 | 1001 | 49.90 |
RLS can provide defense in depth for multi-tenant systems.
It also increases complexity. Policies must be tested for:
- reads,
- inserts,
- updates,
- deletes,
- privileged/bypass roles.
Views as security boundaries
A view can expose only selected columns.
Base table:
customers
βββ id
βββ name
βββ email
βββ password_hash
βββ internal_risk_score
Reporting view:
CREATE VIEW reporting.customers AS
SELECT id, name
FROM app.customers;
Example app.customers input:
| id | name | password_hash | internal_risk_score | |
|---|---|---|---|---|
| 1 | Alice | alice@example.com | $argon2id$... |
7 |
Querying reporting.customers returns only the exposed columns:
| id | name |
|---|---|
| 1 | Alice |
The reporting account receives access to the view, not the base table.
This is useful for minimizing sensitive-data exposure.
Column privileges
Some databases support column-level grants.
PostgreSQL can grant access to specific columns:
GRANT SELECT (customer_id, display_name)
ON app.customers
TO analyst;
Column permissions can help, but views are often clearer when the exposed interface has business meaning.
Human administrators
Human administration should be attributable.
Prefer:
alice_admin
bob_admin
carol_admin
over:
shared_dba
Named identities improve:
- auditability,
- revocation,
- incident investigation,
- privilege reviews.
Emergency "break-glass" access should be exceptional, monitored, and tested.
Service accounts
Each application or service should generally have its own database identity.
Bad:
website ββ
worker ββΌβββΊ db_user
billing ββ
Better:
website -> website_db
worker -> worker_db
billing -> billing_db
If one credential leaks, only that service's privileges need to be considered.
Password authentication
When passwords are used:
- use long randomly generated service passwords,
- store them in a secret manager,
- rotate them,
- do not commit them,
- do not share human accounts.
Do not force arbitrary periodic rotation of human passwords without a threat-driven reason if the policy encourages weak predictable changes. Follow the organization's identity provider and current security guidance.
Workload identity
Cloud platforms increasingly support short-lived identity tokens or managed workload identities.
Conceptually:
application workload
β
βΌ
cloud identity service
β
βΌ
short-lived database token
β
βΌ
database
Advantages can include:
- fewer long-lived static passwords,
- centralized revocation,
- identity-based audit trails.
The exact mechanism depends on the database and platform.
Client certificates
Mutual TLS can authenticate clients with certificates.
client certificate
β
βΌ
TLS handshake
β
βΌ
database validates issuer / identity
Certificates still need:
- issuance,
- rotation,
- revocation,
- private-key protection.
They move credential management; they do not eliminate it.
Connection pooling
A connection pool often uses one database identity for many application users.
Alice ββ
Bob ββΌβββΊ web app ββΊ pool ββΊ app_db_role
Carol ββ
The database may not directly know which end user initiated each request.
If user-level accountability matters, propagate application identity into:
- structured audit events,
- transaction/session metadata where safe,
- request IDs.
Do not create thousands of database logins merely to imitate application users unless the architecture genuinely requires that model.
Privilege review
Privileges accumulate.
A quarterly or release-based review should answer:
- Which login roles still exist?
- Which services still use them?
- Which group roles are they members of?
- Which schemas/tables/functions can they access?
- Are there unused elevated roles?
- Are former employees or retired workloads removed?
- Do default privileges still match policy?
PostgreSQL inspection queries
List login-capable roles:
SELECT
rolname,
rolsuper,
rolcreatedb,
rolcreaterole,
rolcanlogin
FROM pg_roles
WHERE rolcanlogin
ORDER BY rolname;
Example pg_roles input rows:
| rolname | rolsuper | rolcreatedb | rolcreaterole | rolcanlogin |
|---|---|---|---|---|
| analyst | false | false | false | true |
| app_group | false | false | false | false |
The login-role query returns:
| rolname | rolsuper | rolcreatedb | rolcreaterole | rolcanlogin |
|---|---|---|---|---|
| analyst | false | false | false | true |
List role memberships:
SELECT
member_role.rolname AS member,
granted_role.rolname AS granted_role
FROM pg_auth_members m
JOIN pg_roles granted_role
ON granted_role.oid = m.roleid
JOIN pg_roles member_role
ON member_role.oid = m.member
ORDER BY member, granted_role;
Example input catalog rows:
pg_auth_members
| member oid | roleid |
|---|---|
| 101 | 200 |
pg_roles
| oid | rolname |
|---|---|
| 101 | analyst |
| 200 | reporting_reader |
The joined result is:
| member | granted_role |
|---|---|
| analyst | reporting_reader |
Review these queries from an appropriately privileged administrative account.
Separation of duties
A mature environment may separate:
- deployment/migration access,
- application runtime access,
- backup administration,
- security audit review,
- infrastructure administration.
The objective is to avoid one credential being able to:
- change data,
- erase the evidence,
- alter backups,
- grant itself more access.
Separation of duties reduces insider and credential-compromise risk.
Temporary elevation
Sometimes a service needs temporary elevated access.
Prefer:
normal low privilege
β
approved elevation
β
short lifetime
β
automatic expiry
over permanently granting powerful privileges "just in case."
Cloud IAM systems and privileged-access-management tools can support this workflow.
Failed authentication
Monitor failed logins, but interpret them carefully.
A spike can mean:
- attack attempts,
- expired credentials,
- broken deployment configuration,
- a retired service still retrying.
Useful event context includes:
- database user,
- source network,
- timestamp,
- authentication mechanism,
- application name.
Avoid logging plaintext passwords or authentication secrets.
Common mistakes
Application runs as superuser
A single SQL injection bug becomes catastrophic.
Shared DBA password
Actions cannot be reliably attributed.
Grants only ever increase
Old permissions remain after responsibilities change.
New tables are created with accidental defaults
Use migration and default-privilege policies.
Role names describe people rather than responsibilities
Prefer reusable permission roles such as reporting_read.
Security exists only in application code
Database permissions provide an independent layer.
Practical access-control checklist
Before production:
- Does every workload have a distinct identity?
- Does the application avoid superuser/owner access?
- Are permissions grouped into clear roles?
- Are default privileges defined?
- Is
PUBLICaccess understood? - Are sensitive columns hidden from reporting users?
- Does multi-tenancy need row-level security?
- Are powerful functions reviewed?
- Are human administrators individually identifiable?
- Can credentials be revoked quickly?
- Are role memberships periodically reviewed?
- Are failed authentication events monitored?