← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202625 phút đọc21 min read

Bảo mật & Access ControlSecurity & Access Control

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Bảo mật một PostgreSQL server thực chất là ba câu hỏi tách biệt chồng lên nhau. Thứ nhất: ai được phép mở connection, và từ đâu — đây là authentication, được kiểm soát bởi pg_hba.conf và lớp transport (TCP thường hay TLS). Thứ hai: sau khi đã kết nối, role này được phép làm gì — đây là authorization, được kiểm soát bởi role, privilege, và GRANT/REVOKE. Thứ ba, một lớp mà nhiều team hay quên: trong một table mà role này đã được phép query, nó được thấy những row cụ thể nào — đây là Row-Level Security. Chỉ cần sai một trong ba lớp này, hai lớp còn lại cũng không cứu được: một hệ thống privilege được cấu hình hoàn hảo trở nên vô nghĩa nếu pg_hba.conf cho phép bất kỳ ai kết nối với trust, và authentication chặt chẽ cũng chẳng giúp gì nếu mọi application role đều được cấp SUPERUSER vì lười.

Bài viết này giả định bạn đã biết cách cài đặt PostgreSQL và kết nối bằng psql (đã nói ở ./02-installation-setup-and-connecting.md) và sẽ đi qua toàn bộ lớp phòng thủ: role và các attribute của nó, các phương thức authentication mà PostgreSQL hỗ trợ, cách đánh giá rule trong pg_hba.conf, mã hóa transport bằng SSL/TLS, mô hình privilege GRANT/REVOKE cùng default privileges, Row-Level Security cho multi-tenant và kiểm soát truy cập ở mức chi tiết, một ghi chú ngắn về anonymization dữ liệu cho các bản copy non-production, và hardening ở tầng OS với SELinux. Mọi thứ ở đây đều xoay quanh nguyên tắc least privilege — mỗi cơ chế bên dưới tồn tại để thu hẹp những gì một connection, role, hoặc query cụ thể được phép làm, không bao giờ để mở rộng nó.

Kiến thức nền tảng

Role: mô hình user/group hợp nhất của PostgreSQL

Nhiều hệ quản trị database tách biệt “user” (thứ đăng nhập được) khỏi “group” (tập hợp các quyền). PostgreSQL thì không — nó chỉ có một khái niệm duy nhất gọi là role. Một role có thể đăng nhập như một user truyền thống, đóng vai trò một group để các role khác inherit, hoặc cả hai cùng lúc. Việc một role hoạt động như “user” hay “group” hoàn toàn phụ thuộc vào các attribute của nó và việc các role khác có được grant membership vào nó hay không.

CREATE USER không phải một object khác — nó chỉ là một alias mỏng của CREATE ROLE kèm theo attribute LOGIN ngầm định:

-- hai câu lệnh này gần như tương đương về chức năng
CREATE USER alice WITH PASSWORD 'a-strong-password';
CREATE ROLE alice WITH LOGIN PASSWORD 'a-strong-password';

Các role attribute kiểm soát việc role được phép làm gì ở mức instance, độc lập với privilege trên object:

AttributeÝ nghĩaMặc định
LOGINRole có thể authenticate và mở connection (nếu không có, role chỉ thuần túy là một “group”)NOLOGIN với CREATE ROLE, LOGIN với CREATE USER
SUPERUSERBỏ qua mọi kiểm tra quyền trong database — truy cập toàn quyền, không giới hạnNOSUPERUSER
CREATEDBRole có thể tạo database mớiNOCREATEDB
CREATEROLERole có thể tạo, sửa, xóa các role khác (không phải superuser)NOCREATEROLE
REPLICATIONRole có thể khởi tạo connection replication (physical/streaming)NOREPLICATION
PASSWORDĐặt secret dùng để authenticate với các phương thức dựa trên passwordkhông có
CONNECTION LIMIT nGiới hạn số connection đồng thời cho role này-1 (không giới hạn)
VALID UNTIL 'timestamp'Password/role tự động hết hạn sau thời điểm nàykhông có (không bao giờ hết hạn)

Một application role tuân thủ least-privilege gần như không bao giờ nên có SUPERUSER, CREATEDB, CREATEROLE, hay REPLICATION — đó là các attribute chỉ dành cho DBA. Một application role thực tế nên trông như thế này:

-- một service account cho application: đăng nhập được, không tạo được database,
-- không tạo được role khác, không replicate được, giới hạn connection,
-- và có ngày bắt buộc rotate password
CREATE ROLE app_orders WITH
  LOGIN
  PASSWORD 'use-a-secrets-manager-in-real-life'
  NOSUPERUSER
  NOCREATEDB
  NOCREATEROLE
  NOREPLICATION
  CONNECTION LIMIT 50
  VALID UNTIL '2027-01-01';

Role này có thể kết nối, và một khi được grant đúng privilege trên object (xem bên dưới), nó làm đúng việc của nó — không hơn không kém. Nếu credential bị lộ, phạm vi thiệt hại bị giới hạn bởi những gì đã được grant tường minh cho role đó, chứ không phải toàn bộ cluster.

Các mô hình authentication

Authentication trả lời câu hỏi “connection này thực sự là ai?” trước khi bất kỳ kiểm tra privilege nào chạy. PostgreSQL hỗ trợ nhiều phương thức có thể cắm vào (pluggable), được cấu hình theo từng rule trong pg_hba.conf.

Phương thứcCách xác minh danh tínhPhù hợp ở đâu
trustKhông kiểm tra gì cả — chấp nhận bất kỳ role nào được claim mà không cần credentialKhông bao giờ dùng qua network; nhiều lắm chỉ dùng cho máy dev local, single-user, không chứa dữ liệu nhạy cảm
password / md5Client gửi password; server so khớp với MD5 hash đã lưuChỉ còn tồn tại vì lý do legacy — đang bị loại bỏ dần để thay bằng scram-sha-256
scram-sha-256Client và server thực hiện giao thức challenge-response SCRAM-SHA-256; password không bao giờ được gửi qua đường truyền, và verifier được lưu đã salt và iterateMặc định hiện đại cho password authentication — đây là giá trị password_encryption nên được đặt
peerServer đọc danh tính ở mức OS của process ở đầu bên kia một Unix socket local và ánh xạ nó vào tên role PostgreSQL (hoặc một tên đã map)Các connection admin/bảo trì local trên cùng host — ví dụ sudo -u postgres psql
identÝ tưởng giống peer nhưng qua TCP, hỏi một ident server từ xa để xác nhận OS userHiếm dùng ngày nay; giao thức ident không có authentication và dễ bị giả mạo trên mạng không tin cậy, nên chủ yếu chỉ còn mang tính lịch sử
certClient trình một chứng chỉ X.509 TLS được ký bởi CA mà server tin tưởng; CN của chứng chỉ (hoặc một attribute đã map) trở thành danh tính roleMôi trường mutual-auth chặt chẽ — connection service-to-service, mạng zero-trust
ldapServer chuyển password sang một LDAP directory (simple bind hoặc search+bind) để xác minhTập trung hóa authentication vào một corporate directory sẵn có
gss / sspiDùng GSSAPI/Kerberos (hoặc SSPI trên Windows) để authenticate, cho phép single sign-on trong một Kerberos realmMôi trường enterprise đã chạy sẵn Kerberos

Nâng cấp quan trọng nhất mà hầu hết các cài đặt PostgreSQL cần là ngừng dùng md5 để chuyển sang scram-sha-256. Xác thực password dựa trên MD5 dễ bị tấn công dictionary offline nếu file password bị đánh cắp, và cũng không bảo vệ password khi truyền đi theo cách mà challenge-response của SCRAM làm được. Kể từ PostgreSQL 14, scram-sha-256 đã là giá trị mặc định của password_encryption, nhưng các cluster cũ được upgrade tại chỗ thường vẫn mang MD5 hash và các rule pg_hba.conf từ trước khi chuyển đổi.

-- kiểm tra cấu hình hiện tại
SHOW password_encryption;

-- đặt mặc định cho password mới về sau
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();

-- re-hash password của một role hiện có bằng phương thức mới
-- (đặt lại password sẽ mã hóa lại theo password_encryption hiện tại)
ALTER ROLE app_orders WITH PASSWORD 'the-same-or-a-new-password';

Migrate các role hiện có đòi hỏi reset password của từng role (không có cách nào convert MD5 hash sang SCRAM verifier mà không cần plaintext), và mỗi dòng pg_hba.conf tương ứng cần đổi auth-method từ md5 sang scram-sha-256 để thực sự áp dụng giao thức mới.

pg_hba.conf: kiểm soát client nào được kết nối, bằng cách nào

pg_hba.conf (“host-based authentication”) là người gác cổng chạy trước khi bất kỳ SQL nào được thực thi. Mỗi dòng là một rule khớp với một tổ hợp loại connection, database, user, và địa chỉ nguồn, đi kèm phương thức authentication áp dụng nếu connection khớp.

# TYPE    DATABASE   USER        ADDRESS               METHOD

# 1. Connection local qua Unix socket từ OS user postgres: dùng peer auth
local     all        postgres                          peer

# 2. Connection local qua Unix socket từ role bất kỳ khác: SCRAM password
local     all        all                                scram-sha-256

# 3. Connection TCP loopback (ví dụ app chạy trên cùng host): SCRAM
host      all        all         127.0.0.1/32           scram-sha-256
host      all        all         ::1/128                scram-sha-256

# 4. Connection replication từ một standby đã biết, chỉ qua TLS
hostssl   replication  replicator  10.0.1.20/32          scram-sha-256

# 5. Traffic từ application, từ subnet nội bộ, bắt buộc TLS
hostssl   orders     app_orders   10.0.2.0/24            scram-sha-256

# 6. Mọi thứ khác: từ chối
host      all        all         0.0.0.0/0              reject

Một vài chi tiết quyết định pg_hba.conf có hiệu quả hay không trong thực tế:

SSL/TLS: mã hóa connection client-server

Authentication chứng minh danh tính; TLS bảo vệ dữ liệu khi truyền đi khỏi bị đọc hoặc can thiệp bởi bất kỳ ai đứng trên đường truyền mạng. Hai thứ này độc lập với nhau — bạn có thể có authentication mạnh trên một socket không mã hóa, hoặc mã hóa với authentication yếu — đó là lý do cả hai đều cần được quan tâm.

Trên server, bật TLS cần một cặp certificate/key và một cờ cấu hình:

# postgresql.conf
ssl = on
ssl_cert_file = 'server.crt'
ssl_key_file  = 'server.key'
ssl_ca_file   = 'root.crt'      # cần nếu muốn xác minh certificate của client

Ở phía client, tham số connection sslmode kiểm soát mức độ chặt chẽ khi enforce TLS, và các mode này tạo thành một bậc thang tăng dần về mức đảm bảo:

sslmodeCó mã hóa connection không?Có xác minh certificate của server không?Có chống được MITM không?
disableKhôngKhôngKhông
allowChỉ khi server yêu cầuKhôngKhông
prefer (mặc định của hầu hết client)Thử TLS trước, âm thầm fallback về plaintext nếu TLS thất bạiKhôngKhông
requireKhông — kết nối với bất kỳ server nào nói TLS, kể cả một server có certificate tự ký hoặc saiKhông
verify-caKiểm tra certificate của server được ký bởi CA đáng tinMột phần — không kiểm tra certificate có khớp hostname bạn yêu cầu hay không
verify-fullKiểm tra cả chuỗi CA lẫn việc hostname của certificate khớp với hostname bạn đã kết nối tới

Khoảng cách giữa requireverify-full là nơi phần lớn mọi người bị “dính bẫy”: require đảm bảo đường truyền được mã hóa, nhưng không nói gì về ai ở đầu bên kia — một kẻ tấn công ở mức mạng có thể redirect connection của bạn sang server TLS-terminating của chính hắn sẽ đánh bại require một cách dễ dàng, vì bất kỳ certificate nào trông “hợp lệ” cũng được chấp nhận. Chỉ verify-full mới thực sự xác thực server đúng là cái bạn muốn kết nối tới, lấp khoảng trống đó. Bất kỳ connection string nào xử lý dữ liệu nhạy cảm — traffic application production, đường replication, bất cứ thứ gì đi qua mạng không tin cậy — nên dùng verify-full, không chỉ require.

# một connection string thực sự chống được MITM
postgresql://app_orders@db.internal:5432/orders?sslmode=verify-full&sslrootcert=root.crt

Khái niệm chính

GRANT/REVOKE: hệ thống privilege trên object

Sau khi role đã được authenticate, PostgreSQL kiểm tra privilege ở mức object cho mỗi thao tác. Privilege được grant cho role trên các object database cụ thể — table, sequence, function, schema, và nhiều hơn nữa — và mỗi loại object có tập privilege riêng phù hợp với nó (SELECT, INSERT, UPDATE, DELETE trên table; EXECUTE trên function; USAGE trên schema và sequence; CONNECT trên database; và các loại khác).

-- cho phép app_orders đọc và ghi table orders, nhưng không xóa row
GRANT SELECT, INSERT, UPDATE ON orders TO app_orders;

-- schema cần USAGE tường minh trước khi bất cứ thứ gì bên trong có thể truy cập được
GRANT USAGE ON SCHEMA sales TO app_orders;

-- function cần EXECUTE
GRANT EXECUTE ON FUNCTION sales.recalculate_totals(int) TO app_orders;

-- sequence cần USAGE cho nextval()/currval() (liên quan tới cột serial/identity)
GRANT USAGE ON SEQUENCE orders_id_seq TO app_orders;

-- lấy lại một privilege thì đối xứng
REVOKE DELETE ON orders FROM app_orders;

Một chi tiết quan trọng, hay bị bỏ sót: USAGE trên schema chứa object là điều kiện tiên quyết để dùng bất cứ thứ gì bên trong nó. Grant SELECT trên một table nằm trong schema sales sẽ không có tác dụng gì nếu role chưa bao giờ được grant USAGE ON SCHEMA sales — role thậm chí không thể “thấy” schema để chạm tới table.

Role inheritance: quản lý permission theo group

Thay vì grant cùng một tập privilege cho mười role riêng lẻ từng cái một, PostgreSQL cho phép grant một role cho role khác, biến role được grant thành một group mà thành viên tự động inherit privilege của nó.

-- tạo một role "group" không có khả năng login, chỉ là một tập hợp privilege
CREATE ROLE reporting_readonly NOLOGIN;
GRANT USAGE ON SCHEMA sales TO reporting_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly;

-- các login riêng lẻ của analyst chỉ đơn giản trở thành member của group
CREATE ROLE analyst_bob LOGIN PASSWORD '...';
CREATE ROLE analyst_carol LOGIN PASSWORD '...';

GRANT reporting_readonly TO analyst_bob;
GRANT reporting_readonly TO analyst_carol;

Cả analyst_bobanalyst_carol giờ đều có quyền đọc mọi table trong sales, mà không ai trong hai người được grant trực tiếp bất cứ gì. Khi có analyst mới gia nhập, bạn chỉ cần grant membership vào reporting_readonly một lần; khi policy đọc của schema reporting cần thay đổi, bạn thay đổi nó một lần trên role group và mọi thành viên đều nhận ngay lập tức. Đây là cùng mô hình tư duy với group của OS/directory-service, và là pattern nên dùng bất cứ khi nào có nhiều hơn một vài role cần cùng một tập permission — xem thêm các pattern IAM group/role rộng hơn ở ../devsecops/en/05-identity-and-access-management.md.

Default privileges: giải quyết vấn đề “table của ngày mai”

GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly chỉ ảnh hưởng tới các table đã tồn tại ngay bây giờ. Một table ai đó tạo trong schema đó vào ngày mai sẽ bắt đầu mà không có grant đó — nguồn gốc thường xuyên của các ticket kiểu “sao role reporting không thấy table mới”. ALTER DEFAULT PRIVILEGES giải quyết vấn đề này bằng cách đăng ký một rule đứng vững: “từ bây giờ trở đi, mỗi khi role X tạo một object thuộc loại này trong schema này trong tương lai, tự động áp dụng grant này.”

-- từ bây giờ, mỗi khi role `svc_migrations` tạo table mới trong schema `sales`,
-- tự động grant SELECT trên table đó cho reporting_readonly
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
  GRANT SELECT ON TABLES TO reporting_readonly;

-- tương tự với sequence (lỗ hổng phổ biến: quên sequence của cột serial/identity)
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
  GRANT USAGE ON SEQUENCES TO reporting_readonly;

Mệnh đề FOR ROLE svc_migrations rất quan trọng: rule default privilege được gắn với role tạo ra object, không chỉ với schema. Nếu migration của bạn chạy bằng một role khác với role bạn đã đăng ký rule cho, các table mới tạo sẽ vẫn không nhận được grant — một cạm bẫy thường gặp khi job migration CI/CD chạy bằng một service account chưa được đưa vào câu lệnh ALTER DEFAULT PRIVILEGES.

Mô hình tư duy gắn kết tất cả lại

Việc phân biệt rõ ba lớp sau rất hữu ích, vì chúng dễ bị nhầm lẫn với nhau:

LớpCâu hỏi nó trả lờiCơ chế
Role membership”Role này thuộc những group nào, và nó inherit gì?”GRANT role_a TO role_b
Object privilege”Role này có thể làm gì với các object đã tồn tại?”GRANT SELECT/INSERT/... ON object TO role
Default privilege”Role này sẽ tự động được làm gì với các object tạo ra trong tương lai?”ALTER DEFAULT PRIVILEGES ... GRANT ...
Row-Level Security”Trong các row của một table mà role đã có thể query, nó được phép thấy hoặc sửa những row cụ thể nào?”CREATE POLICY ... ON table USING (...)

Object privilege và default privilege cùng nhau trả lời “table/column/function nào” một role được chạm tới; role membership là cách bạn tránh lặp lại các grant đó cho từng người; RLS là lớp nằm dưới tất cả những thứ đó, thu hẹp truy cập xuống tận từng row riêng lẻ trong một table mà role vẫn được phép query đầy đủ.

Row-Level Security (RLS): giới hạn những row nào một role được thấy

Mọi thứ ở trên đều hoạt động ở mức toàn bộ object — một role hoặc được phép hoặc không được phép SELECT từ một table. Row-Level Security thêm một nút điều chỉnh chi tiết hơn: các policy lọc row nào trong table đó được nhìn thấy hoặc sửa được, được đánh giá theo từng query, một cách trong suốt, mà application không cần nhớ thêm mệnh đề WHERE ở mọi nơi.

Use case kinh điển là SaaS multi-tenant: dữ liệu của nhiều tenant nằm trong một table vật lý dùng chung (để đơn giản hóa vận hành — một schema, một bộ index, một thứ để backup), nhưng query của mỗi tenant chỉ nên bao giờ thấy row của chính họ, không bao giờ thấy row của tenant khác, kể cả khi một query có bug hoặc ORM quên thêm filter.

CREATE TABLE invoices (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id  int NOT NULL,
  customer   text NOT NULL,
  amount     numeric(12,2) NOT NULL
);

-- RLS không có tác dụng gì cho tới khi được bật cho table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- một policy: một row chỉ hiển thị/sửa được nếu tenant_id của nó khớp
-- với tenant_id mà application đã đặt cho session hiện tại
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::int);

Application đặt app.tenant_id một lần mỗi connection hoặc transaction (thường ngay sau khi authenticate end user), và mọi query tiếp theo trên invoices được lọc một cách trong suốt:

-- code application, mỗi request, trước khi chạy query của tenant
SET app.tenant_id = '42';

-- trông như một query không giới hạn, nhưng RLS âm thầm viết lại
-- để chỉ bao giờ trả về row có tenant_id = 42
SELECT * FROM invoices;

Một vài chi tiết vận hành quyết định RLS có thực sự bảo vệ được gì hay không:

RLS là một lớp phòng thủ theo chiều sâu mạnh mẽ dành riêng cho bài toán dạng “table chung, nhiều tenant” hoặc “nhiều loại user với khả năng nhìn thấy row khác nhau”; nó không thay thế GRANT/REVOKE (role vẫn cần privilege cơ bản trên table trước khi policy RLS được xem xét đến), và nó cũng không phải một abstraction miễn phí về hiệu năng — biểu thức của policy được gấp vào query plan và cần được quan tâm về indexing như bất kỳ filter nào khác (một index trên tenant_id gần như là bắt buộc cho ví dụ trên).

Anonymization dữ liệu cho các bản copy non-production

Database production thường xuyên được copy sang staging, QA, hoặc sandbox của developer — và các bản copy đó thường mang cùng dữ liệu PII, dữ liệu tài chính, hoặc các trường nhạy cảm khác như production, chỉ với kiểm soát truy cập yếu hơn xung quanh chúng. Extension postgresql_anonymizer giải quyết trực tiếp vấn đề này bên trong PostgreSQL: nó cho phép bạn khai báo rule masking theo từng cột (thay thế tĩnh, ngẫu nhiên hóa, masking một phần, tổng quát hóa, hoặc thêm noise) và sau đó tạo ra hoặc một bản dump được anonymize hoàn toàn hoặc một view được mask động cho các role cụ thể, mà không cần tự viết một pipeline ETL/scrubbing riêng.

CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.start_dynamic_masking();

SECURITY LABEL FOR anon ON COLUMN customers.email
  IS 'MASKED WITH FUNCTION anon.fake_email()';

SECURITY LABEL FOR anon ON COLUMN customers.national_id
  IS 'MASKED WITH FUNCTION anon.partial(national_id, 2, ''XXXXXXX'', 2)';

Đây là một công cụ cụ thể, native của PostgreSQL, cho một kỷ luật rộng hơn nhiều — phân loại dữ liệu nhạy cảm, chọn giữa masking, tokenization, hay encryption, và quyết định điều gì được phép rời khỏi production — được nói sâu hơn ở ../data-engineer/en/15-data-security-and-compliance.md.

SELinux: mandatory access control ở mức OS như một lớp bổ sung

Mọi thứ đã nói ở trên đều được enforce bởi chính PostgreSQL — nghĩa là một bug trong PostgreSQL, một lỗi cấu hình, hoặc việc process OS postgres bị compromise đều có thể tiềm ẩn khả năng bypass toàn bộ những thứ đó. SELinux (hoặc một Linux Security Module tương đương) thêm một lớp mà PostgreSQL không kiểm soát được: mandatory access control được enforce bởi kernel, giới hạn những gì process postgres được phép làm bất kể các kiểm tra permission nội bộ của chính nó nói gì.

Trên các hệ thống dòng RHEL, tích hợp postgresql-server-selinux (hay sepgsql) cho phép bạn giới hạn process PostgreSQL vào đúng các đường dẫn file, network socket, và system call mà nó cần bằng một SELinux policy, và — thông qua extension sepgsql — tùy chọn mở rộng mandatory access control xuống tận từng object database, được ánh xạ vào SELinux security context. Trong thực tế, phần lớn team nhận được đa số lợi ích chỉ bằng cách chạy PostgreSQL dưới policy enforcing mặc định postgresql_t của SELinux (thay vì tắt SELinux “cho nó chạy được”, một thói quen đáng buồn là khá phổ biến nhưng lại tiêu cực về mặt bảo mật): nghĩa là ngay cả khi kẻ tấn công đạt được khả năng thực thi code bên trong process PostgreSQL, chúng vẫn bị giới hạn trong các file, port, và capability mà policy cho phép, thay vì được toàn quyền trên host. Đây là một lớp chắn thô ở mức OS, cố ý độc lập với — và không thay thế cho — toàn bộ hệ thống role/privilege/RLS mà chính PostgreSQL enforce ở trên.

Best Practices

Nguyên tắcCơ chế PostgreSQL cụ thể
Không bao giờ để application role authenticate như superuserTạo role riêng cho từng application/service với NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION
Không bao giờ tin rằng network là riêng tưRule hostssl trong pg_hba.conf, ssl = on, sslmode=verify-full trên mọi client
Từ chối mặc định, chỉ cho phép tường minhRule deny/reject cuối cùng ở cuối pg_hba.conf; chỉ grant đúng privilege mà role thực sự cần
Thứ tự quan trọng trong pg_hba.confRule cụ thể nhất đặt trước, rule rộng/bắt tất cả đặt sau cùng — rule khớp đầu tiên thắng
Loại bỏ password hashing yếupassword_encryption = scram-sha-256, cấp lại password cho các role còn dùng md5
Quản lý permission theo group, không theo từng ngườiRole group NOLOGIN + GRANT group TO user thay vì lặp lại grant
Đừng quên object của ngày maiALTER DEFAULT PRIVILEGES ... FOR ROLE <creator> IN SCHEMA ...
Cách ly các tenant dùng chung một tableENABLE ROW LEVEL SECURITY + CREATE POLICY ... USING (tenant_id = ...)
Giữ các bản copy non-prod không rò rỉ PII thậtMasking bằng postgresql_anonymizer hoặc một pipeline export-anonymize đúng chuẩn
Thêm một lớp chắn được kernel enforceChạy dưới policy enforcing postgresql_t của SELinux thay vì tắt nó
Rotate và cho hết hạn credentialVALID UNTIL trên role, rotate password định kỳ qua secrets manager
Audit những gì thực sự đã grantĐịnh kỳ rà soát information_schema.role_table_grantspg_roles để phát hiện lệch khỏi mục tiêu least-privilege

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

Securing a PostgreSQL server is really three separate questions stacked on top of each other. First: who is allowed to even open a connection, and from where — that’s authentication, controlled by pg_hba.conf and the transport (plain TCP vs TLS). Second: once connected, what is this role allowed to do — that’s authorization, controlled by roles, privileges, and GRANT/REVOKE. Third, a layer many teams forget: within a table this role can already query, which specific rows can it see — that’s Row-Level Security. Get any one of the three wrong and the other two don’t save you: a perfectly configured privilege system is worthless if pg_hba.conf lets anyone connect as trust, and airtight authentication doesn’t help if every application role is granted SUPERUSER out of laziness.

This note assumes you can already install PostgreSQL and connect to it with psql (covered in ./02-installation-setup-and-connecting.md) and walks through the full defensive stack: roles and their attributes, the authentication methods PostgreSQL supports, pg_hba.conf rule evaluation, transport encryption with SSL/TLS, the GRANT/REVOKE privilege model and default privileges, Row-Level Security for multi-tenant and fine-grained access control, a brief note on data anonymization for non-production copies, and OS-level hardening with SELinux. Everything here is framed around the principle of least privilege — every mechanism below exists to narrow what a given connection, role, or query is allowed to do, never to widen it.

Fundamentals

Roles: PostgreSQL’s unified user/group model

Many database systems separate “users” (things that log in) from “groups” (collections of permissions). PostgreSQL doesn’t — it has a single concept called a role. A role can log in like a traditional user, act as a group that other roles inherit from, or both at once. Whether a role behaves like a “user” or a “group” is entirely determined by its attributes and whether other roles are granted membership in it.

CREATE USER is not a different object — it’s a thin alias for CREATE ROLE that implies the LOGIN attribute:

-- these two are functionally almost identical
CREATE USER alice WITH PASSWORD 'a-strong-password';
CREATE ROLE alice WITH LOGIN PASSWORD 'a-strong-password';

Role attributes control what a role is allowed to do at the instance level, independent of object privileges:

AttributeMeaningDefault
LOGINRole can authenticate and open a connection (without it, the role is purely a “group”)NOLOGIN for CREATE ROLE, LOGIN for CREATE USER
SUPERUSERBypasses every permission check in the database — full, unrestricted accessNOSUPERUSER
CREATEDBRole can create new databasesNOCREATEDB
CREATEROLERole can create, alter, and drop other (non-superuser) rolesNOCREATEROLE
REPLICATIONRole can initiate physical/streaming replication connectionsNOREPLICATION
PASSWORDSets the authentication secret used by password-based auth methodsnone
CONNECTION LIMIT nCaps concurrent connections for this role-1 (unlimited)
VALID UNTIL 'timestamp'Password/role expires automatically after this timenone (never expires)

A least-privilege application role should almost never have SUPERUSER, CREATEDB, CREATEROLE, or REPLICATION — those are DBA-only attributes. A realistic application role looks like this instead:

-- an application service account: can log in, cannot create databases,
-- cannot create other roles, cannot replicate, capped connections,
-- and a forced password rotation date
CREATE ROLE app_orders WITH
  LOGIN
  PASSWORD 'use-a-secrets-manager-in-real-life'
  NOSUPERUSER
  NOCREATEDB
  NOCREATEROLE
  NOREPLICATION
  CONNECTION LIMIT 50
  VALID UNTIL '2027-01-01';

This role can connect and, once granted the right object privileges (see below), do exactly its job — and nothing else. If its credentials leak, the blast radius is bounded by whatever privileges were explicitly granted to it, not the entire cluster.

Authentication models

Authentication answers “who is this connection, really?” before any privilege check ever runs. PostgreSQL supports several pluggable methods, configured per-connection-rule in pg_hba.conf.

MethodHow it verifies identityWhere it’s appropriate
trustDoesn’t verify anything — accepts any claimed role name with no credentialNever over the network; at most for local, single-user development boxes with no sensitive data
password / md5Client sends a password; server checks it against a stored MD5 hashLegacy only — being phased out in favor of scram-sha-256
scram-sha-256Client and server run the SCRAM-SHA-256 challenge-response protocol; the password itself is never sent over the wire, and the stored verifier is salted and iteratedThe modern default for password authentication — this is what password_encryption should be set to
peerServer reads the OS-level identity of the process on the other end of a local Unix socket and matches it to the PostgreSQL role name (or a mapped name)Local admin/maintenance connections on the same host — e.g. sudo -u postgres psql
identSame idea as peer but over TCP, asking a remote ident server to vouch for the OS userRare today; the ident protocol is unauthenticated and easy to spoof on untrusted networks, so it’s mostly historical
certClient presents an X.509 TLS certificate signed by a CA the server trusts; the certificate’s CN (or a mapped attribute) becomes the role identityStrong mutual-auth environments — service-to-service connections, zero-trust networks
ldapServer forwards the password to an LDAP directory (simple bind or search+bind) for verificationCentralizing auth against an existing corporate directory
gss / sspiUses GSSAPI/Kerberos (or SSPI on Windows) for authentication, enabling single sign-on within a Kerberos realmEnterprise environments already running Kerberos

The single most important upgrade most PostgreSQL installs need is retiring md5 in favor of scram-sha-256. MD5-based password verification is vulnerable to offline dictionary attacks against a stolen password file and doesn’t protect the password in transit the way SCRAM’s challenge-response does. Since PostgreSQL 14, scram-sha-256 has been the default value of password_encryption, but older clusters upgraded in place often still carry md5 hashes and pg_hba.conf rules from before the switch.

-- check what's currently configured
SHOW password_encryption;

-- set the default for new passwords going forward
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();

-- re-hash an existing role's password using the new method
-- (re-setting the password re-encodes it with the current password_encryption setting)
ALTER ROLE app_orders WITH PASSWORD 'the-same-or-a-new-password';

Migrating existing roles requires resetting each password (there’s no way to convert an md5 hash to a SCRAM verifier without the plaintext), and every corresponding pg_hba.conf line needs its auth-method changed from md5 to scram-sha-256 to actually enforce the new protocol.

pg_hba.conf: controlling which clients can connect, how

pg_hba.conf (“host-based authentication”) is the gatekeeper that runs before any SQL is executed. Every line is a rule matching some combination of connection type, database, user, and source address, paired with the authentication method to apply if the connection matches.

# TYPE    DATABASE   USER        ADDRESS               METHOD

# 1. Local Unix-socket connections from the postgres OS user: peer auth
local     all        postgres                          peer

# 2. Local Unix-socket connections from any other role: SCRAM password
local     all        all                                scram-sha-256

# 3. Loopback TCP connections (e.g. an app on the same host): SCRAM
host      all        all         127.0.0.1/32           scram-sha-256
host      all        all         ::1/128                scram-sha-256

# 4. Replication connections from a known standby, over TLS only
hostssl   replication  replicator  10.0.1.20/32          scram-sha-256

# 5. Application traffic from the internal app subnet, TLS required
hostssl   orders     app_orders   10.0.2.0/24            scram-sha-256

# 6. Everything else: reject
host      all        all         0.0.0.0/0              reject

A few details make or break a pg_hba.conf in practice:

SSL/TLS: encrypting the client-server connection

Authentication proves identity; TLS protects the data in transit from being read or tampered with by anyone sitting on the network path. The two are independent — you can have strong authentication over an unencrypted socket, or encryption with weak authentication — which is why both need attention.

On the server, enabling TLS requires a certificate/key pair and a config flag:

# postgresql.conf
ssl = on
ssl_cert_file = 'server.crt'
ssl_key_file  = 'server.key'
ssl_ca_file   = 'root.crt'      # needed if verifying client certificates

On the client side, the sslmode connection parameter controls how strictly TLS is enforced, and the modes form an escalating ladder of guarantees:

sslmodeEncrypts the connection?Verifies server certificate?Protects against MITM?
disableNoNoNo
allowOnly if the server insistsNoNo
prefer (default for most clients)Tries TLS first, silently falls back to plaintext if TLS failsNoNo
requireYesNo — connects to any server that speaks TLS, even one with a self-signed or wrong certificateNo
verify-caYesChecks the server certificate is signed by a trusted CAPartial — doesn’t check the certificate matches the hostname you asked for
verify-fullYesChecks the CA chain and that the certificate’s hostname matches the one you connected toYes

The gap between require and verify-full is where most people get burned: require guarantees the wire is encrypted, but says nothing about who is on the other end — a network-level attacker who can redirect your connection to their own TLS-terminating server defeats require trivially, because any valid-looking certificate is accepted. Only verify-full actually authenticates the server as the one you intended to reach, closing that gap. Any connection string handling sensitive data — production application traffic, replication links, anything crossing an untrusted network — should use verify-full, not just require.

# a client connection string that actually resists MITM
postgresql://app_orders@db.internal:5432/orders?sslmode=verify-full&sslrootcert=root.crt

Key Concepts

GRANT/REVOKE: the object privilege system

Once a role is authenticated, PostgreSQL checks object-level privileges for every operation. Privileges are granted to roles on specific database objects — tables, sequences, functions, schemas, and more — and each object type has its own relevant privilege set (SELECT, INSERT, UPDATE, DELETE on tables; EXECUTE on functions; USAGE on schemas and sequences; CONNECT on databases; and others).

-- allow app_orders to read and write the orders table, but not delete rows
GRANT SELECT, INSERT, UPDATE ON orders TO app_orders;

-- schemas need explicit USAGE before anything inside them is reachable at all
GRANT USAGE ON SCHEMA sales TO app_orders;

-- functions need EXECUTE
GRANT EXECUTE ON FUNCTION sales.recalculate_totals(int) TO app_orders;

-- sequences need USAGE for nextval()/currval() (relevant for serial/identity columns)
GRANT USAGE ON SEQUENCE orders_id_seq TO app_orders;

-- taking a privilege back is symmetric
REVOKE DELETE ON orders FROM app_orders;

An important, often-missed detail: USAGE on the containing schema is a prerequisite for using anything inside it. Granting SELECT on a table sitting in schema sales does nothing useful if the role was never granted USAGE ON SCHEMA sales — the role can’t even “see” the schema to reach the table.

Role inheritance: group-based permission management

Instead of granting the same set of privileges to ten individual roles one at a time, PostgreSQL lets you grant one role to another, turning the granted role into a group whose privileges the member automatically inherits.

-- create a "group" role that holds no login capability, just a bundle of privileges
CREATE ROLE reporting_readonly NOLOGIN;
GRANT USAGE ON SCHEMA sales TO reporting_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly;

-- individual analyst logins simply become members of the group
CREATE ROLE analyst_bob LOGIN PASSWORD '...';
CREATE ROLE analyst_carol LOGIN PASSWORD '...';

GRANT reporting_readonly TO analyst_bob;
GRANT reporting_readonly TO analyst_carol;

Both analyst_bob and analyst_carol now have read access to every table in sales, without either of them being granted anything directly. When a new analyst joins, you grant membership in reporting_readonly once; when the reporting schema’s read policy needs to change, you change it once on the group role and every member picks it up immediately. This is the same mental model as OS/directory-service groups, and it’s the pattern to reach for whenever more than a couple of roles need the same permission set — see also the broader IAM group/role patterns in ../devsecops/en/05-identity-and-access-management.md.

Default privileges: solving “tomorrow’s table”

GRANT SELECT ON ALL TABLES IN SCHEMA sales TO reporting_readonly only affects tables that exist right now. A table someone creates in that schema tomorrow starts with no such grant — a constant source of “why can’t the reporting role see the new table” tickets. ALTER DEFAULT PRIVILEGES fixes this by registering a standing rule: “whenever role X creates an object of this type in this schema in the future, automatically apply this grant.”

-- from now on, whenever role `svc_migrations` creates a new table in schema `sales`,
-- automatically grant SELECT on it to reporting_readonly
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
  GRANT SELECT ON TABLES TO reporting_readonly;

-- same idea for sequences (common gap: forgetting serial/identity columns' sequences)
ALTER DEFAULT PRIVILEGES FOR ROLE svc_migrations IN SCHEMA sales
  GRANT USAGE ON SEQUENCES TO reporting_readonly;

The FOR ROLE svc_migrations clause matters: default privilege rules are keyed to the role that creates the object, not to the schema alone. If your migrations run as a different role than the one you registered the rule for, newly created tables still won’t pick up the grant — a frequent gotcha when CI/CD migration jobs run as a service account that wasn’t included in the ALTER DEFAULT PRIVILEGES statement.

A mental model tying it together

It helps to keep three distinct layers straight, because they’re easy to conflate:

LayerQuestion it answersMechanism
Role membership”What groups is this role part of, and what does it inherit?”GRANT role_a TO role_b
Object privileges”What can this role do to objects that already exist?”GRANT SELECT/INSERT/... ON object TO role
Default privileges”What will this role automatically be able to do to objects created in the future?”ALTER DEFAULT PRIVILEGES ... GRANT ...
Row-Level Security”Of the rows in a table this role can already query, which specific ones is it allowed to see or touch?”CREATE POLICY ... ON table USING (...)

Object privileges and default privileges together answer “which tables/columns/functions” a role can touch; role membership is how you avoid repeating those grants per person; RLS is the layer underneath all of that, narrowing access down to individual rows within a table the role is otherwise fully allowed to query.

Row-Level Security (RLS): restricting which rows a role can see

Everything above operates at the level of whole objects — a role either can or cannot SELECT from a table. Row-Level Security adds a finer dial: policies that filter which rows within that table are visible or modifiable, evaluated per-query, transparently, without the application needing to remember to add a WHERE clause everywhere.

The textbook use case is multi-tenant SaaS: many tenants’ data lives in one shared physical table (for operational simplicity — one schema, one set of indexes, one thing to back up), but each tenant’s queries should only ever see their own rows, never another tenant’s, even if a query has a bug or an ORM forgets a filter.

CREATE TABLE invoices (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id  int NOT NULL,
  customer   text NOT NULL,
  amount     numeric(12,2) NOT NULL
);

-- RLS does nothing until it's turned on for the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- a policy: a row is only visible/modifiable if its tenant_id matches
-- the tenant_id the application set for the current session
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::int);

The application sets app.tenant_id once per connection or transaction (typically right after authenticating the end user), and every subsequent query against invoices is transparently filtered:

-- application code, per request, before running tenant queries
SET app.tenant_id = '42';

-- this looks like an unrestricted query, but RLS silently rewrites it
-- to only ever return rows where tenant_id = 42
SELECT * FROM invoices;

A few operational details matter for RLS to actually protect anything:

RLS is a strong defense-in-depth layer specifically for the “shared table, many tenants” or “many user classes with different row visibility” shape of problem; it doesn’t replace GRANT/REVOKE (a role still needs base table privileges before RLS policies are even consulted), and it isn’t a performance-free abstraction — the policy’s expression is folded into the query plan and needs the same indexing attention as any other filter (an index on tenant_id is essentially mandatory for the example above).

Data anonymization for non-production copies

Production databases routinely get copied into staging, QA, or developer sandboxes — and those copies frequently carry the same PII, financial data, or other sensitive fields as production, just with weaker access controls around them. The postgresql_anonymizer extension addresses this directly inside PostgreSQL: it lets you declare per-column masking rules (static substitution, randomization, partial masking, generalization, or noise) and then produce either a fully anonymized dump or a dynamically masked view for specific roles, without hand-writing a separate ETL/scrubbing pipeline.

CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.start_dynamic_masking();

SECURITY LABEL FOR anon ON COLUMN customers.email
  IS 'MASKED WITH FUNCTION anon.fake_email()';

SECURITY LABEL FOR anon ON COLUMN customers.national_id
  IS 'MASKED WITH FUNCTION anon.partial(national_id, 2, ''XXXXXXX'', 2)';

This is one specific, PostgreSQL-native tool for a much broader discipline — classifying sensitive data, choosing masking versus tokenization versus encryption, and deciding what’s allowed to leave production at all — covered in depth in ../data-engineer/en/15-data-security-and-compliance.md.

SELinux: OS-level mandatory access control as an additional layer

Everything covered so far is enforced by PostgreSQL itself — which means a bug in PostgreSQL, a misconfiguration, or a compromise of the postgres OS process can potentially bypass all of it. SELinux (or an equivalent Linux Security Module) adds a layer PostgreSQL has no control over: mandatory access control enforced by the kernel, confining what the postgres process is allowed to do regardless of what its own internal permission checks say.

On RHEL-family systems, the postgresql-server-selinux (or sepgsql) integration lets you confine the PostgreSQL process to its expected file paths, network sockets, and system calls using an SELinux policy, and — via the sepgsql extension — optionally extend mandatory access control down to individual database objects, mapped to SELinux security contexts. In practice, most teams get the bulk of the benefit from just running PostgreSQL under SELinux’s default postgresql_t enforcing policy (as opposed to disabling SELinux “to make it work,” which is a distressingly common but security-negative habit): it means that even if an attacker achieves code execution inside the PostgreSQL process, they’re still constrained to the files, ports, and capabilities the policy allows, rather than having the full run of the host. This is a coarse-grained, OS-level backstop, deliberately independent from — and not a replacement for — everything PostgreSQL’s own role/privilege/RLS system enforces above.

Best Practices

PrincipleConcrete PostgreSQL mechanism
Never let application roles authenticate as superuserCreate dedicated roles per application/service with NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION
Never trust the network to be privatehostssl rules in pg_hba.conf, ssl = on, sslmode=verify-full on every client
Deny by default, allow explicitlyTerminal reject/deny rule at the bottom of pg_hba.conf; grant only the privileges a role actually needs
Order matters in pg_hba.confMost specific rules first, broad/catch-all rules last — first match wins
Retire weak password hashingpassword_encryption = scram-sha-256, re-issue passwords for roles still on md5
Manage permissions by group, not per-personNOLOGIN group roles + GRANT group TO user instead of duplicating grants
Don’t forget tomorrow’s objectsALTER DEFAULT PRIVILEGES ... FOR ROLE <creator> IN SCHEMA ...
Isolate tenants sharing one tableENABLE ROW LEVEL SECURITY + CREATE POLICY ... USING (tenant_id = ...)
Keep non-prod copies from leaking real PIIpostgresql_anonymizer masking or a proper anonymized-export pipeline
Add a kernel-enforced backstopRun under SELinux’s postgresql_t enforcing policy rather than disabling it
Rotate and expire credentialsVALID UNTIL on roles, periodic password rotation via a secrets manager
Audit what’s actually grantedRegularly review information_schema.role_table_grants and pg_roles for drift from intended least-privilege

References