Skip to content

Postgres 18 OAuth through PgBouncer and service accounts

Last updated

View as Markdown

Quick answer: A client cannot log in to PgBouncer with an OAuth token. Version 1.25.2 has no oauth value for auth_type and refuses to load a config that names one. That rules out one path rather than the whole idea. The practical arrangement has two doors: OAuth directly to PostgreSQL where the identity matters, and a per-workload service account through the pooler for application traffic.

PostgreSQL 18 added oauth as a pg_hba.conf authentication method, so a client can present an OAuth 2.0 bearer token instead of a password. The token does not have to stand for a person. A workload can hold one, and the PostgreSQL 18 OAuth documentation describes libpq as an OAuth client rather than assuming a human is using a browser.

What the method buys you depends on things PostgreSQL does not do for you. Expiry and revocation are properties of your tokens and your validator, not of the HBA method: a self-contained signed token stays acceptable until it expires unless the validator you supply asks the provider. The PostgreSQL 18 OAuth authentication documentation states the division clearly: “PostgreSQL does not provide an authorization server; it is the responsibility of the OAuth provider.”

The next question is whether this works when applications connect through PgBouncer.

Not as a login to PgBouncer. “PgBouncer cannot do OAuth” and “OAuth cannot be used where PgBouncer exists” are different claims, and only the first is true.

The server side is one GUC. Measured on the official postgres:18.6 image:

SELECT name, setting, context FROM pg_settings WHERE name LIKE '%oauth%';
name | setting | context
---------------------------+---------+---------
oauth_validator_libraries | | sighup

Empty, and that is the default. PostgreSQL provides the interface and you supply the validator. The validator can verify a signed token against cached keys, call the provider’s introspection endpoint, or use a provider-specific method. PostgreSQL does not prescribe one and does not ship a general-purpose one.

The GUC is not the whole configuration surface. The rest is in pg_hba.conf, and the PostgreSQL 18 OAuth documentation gives each option its own requirement:

HBA optionwhat the PostgreSQL 18 OAuth documentation says
issuer“This parameter is required.”
scope“This parameter is required.”
validator“This parameter is optional unless oauth_validator_libraries contains more than one library, in which case it is required.”
map“This parameter is optional.”
delegate_ident_mapping“This parameter is incompatible with map.”

delegate_ident_mapping hands the role decision to your validator, and the same PostgreSQL 18 OAuth documentation warns that it “requires careful implementation of the OAuth validator, which must determine whether the provided token carries sufficient end-user privileges in addition to the standard checks required of all validators. Use with caution.”

The issuer string is matched exactly, not loosely. From the same PostgreSQL 18 OAuth documentation: “The OAuth client in libpq requires the server’s issuer setting to exactly match the issuer identifier which is provided in the discovery document, which must in turn match the client’s oauth_issuer setting. No variations in case or formatting are permitted.”

The client half is optional, and on this image it is absent

Section titled “The client half is optional, and on this image it is absent”

The build can look like it supports OAuth when it does not at runtime. The PostgreSQL 18 libpq OAuth documentation says libpq “implements support for the OAuth v2 Device Authorization client flow, documented in RFC 8628, as an optional module”, and that the module must be installed for libpq to use it.

How that optional module is packaged depends on your distribution. The following is scoped to one artifact: the official postgres:18.6 image, digest sha256:86c951e05bf56c93d95d397747fb8820ac76cc3bedb78f43abd83eedbe3666ae, linux/amd64, carrying the Debian PGDG packages at version 18.6-1.pgdg13+2. On that image:

pg_config --configure -> includes '--with-libcurl'
ls /usr/lib/.../libpq-oauth* -> No such file or directory
ls /lib/.../libcurl* -> No such file or directory
dpkg -s libpq5 | grep Suggests
-> Suggests: libpq-oauth

The feature was compiled, but the package libpq5 only suggests the module, so most installations do not pull it in. Check for the module, not just the configure flag: --with-libcurl was present on an image where libcurl itself was not installed.

Two more client constraints, from the same PostgreSQL 18 libpq OAuth documentation, determine whether people can connect directly with OAuth in your environment. The connection string “must at minimum contain oauth_issuer and oauth_client_id”, and “The builtin Device Authorization flow is not currently supported on Windows. Custom client flows may still be implemented.” Non-libpq drivers, JDBC and ODBC stacks, and GUI tools each need to be checked rather than assumed to behave like psql.

PostgreSQL 18 shipped the protocol, the HBA method, validator loading, libpq support and an API for custom client flows. It did not ship a validator for your identity provider, and on most distributions the optional client module is not installed until you add it.

A bad oauth line is silent on reload and fatal on restart

Section titled “A bad oauth line is silent on reload and fatal on restart”

The difference is between reload and startup, and it is not specific to OAuth.

Add host all all 0.0.0.0/0 oauth issuer="..." scope="openid" with no validator configured, then reload. Measured on 18.6:

LOG: oauth_validator_libraries must be set for authentication method oauth
CONTEXT: line 129 of configuration file ".../pg_hba.conf"
LOG: .../pg_hba.conf was not reloaded

The server kept running and kept serving the previous rules. The change had no effect.

The same file on a restart, measured on a fresh 18.6 container:

LOG: oauth_validator_libraries must be set for authentication method oauth
CONTEXT: line 129 of configuration file ".../pg_hba.conf"
FATAL: could not load /var/lib/postgresql/18/docker/pg_hba.conf

The container exited. A cluster can therefore carry a broken pg_hba.conf for weeks, serving the rules it loaded before the edit, and then fail to come back the first time anything restarts it. The outage may occur long after the edit.

Check the parse result rather than the log every time you touch the file:

SELECT line_number, type, database, user_name, auth_method, options, error
FROM pg_hba_file_rules
WHERE error IS NOT NULL;

On the broken file that returned one row. It names the line rather than the rule, because the rule never parsed:

line_number | type | auth_method | error
-------------+------+-------------+---------------------------------------
129 | | | oauth_validator_libraries must be set
for authentication method oauth

The view reads the file on disk, not the configuration the server is running, so it tells you what the next restart will find.

An empty result is necessary but not sufficient. It proves the file parses. It does not prove the policy works. That requires a successful connection as the expected identity, a check that the matching record is the intended one, and negative tests: an expired token, the wrong issuer, a missing scope, a subject with no role mapping, and the validator being down.

PgBouncer 1.25.2, given auth_type = oauth:

ERROR invalid value "oauth" for parameter auth_type in configuration (/tmp/p.ini:7)
FATAL cannot load config file

The config is not loaded, so this pooler does not start.

Every documented value was then tested one at a time against the same binary. plain, trust, md5, scram-sha-256, hba and any started. cert was refused for a different and specific reason, which is the shape of a value the build has:

ERROR auth_type=cert requires client_tls_sslmode=SSLMODE_VERIFY_FULL

oauth, password, crypt, gss, sspi, radius, peer and scram were all rejected as invalid values. So were ldap and pam, and those two are not the same case as the rest.

ldap and pam are build options, not missing features

Section titled “ldap and pam are build options, not missing features”

Both are documented. The PgBouncer configuration reference lists ldap, “Users are authenticated against an LDAP server, like in PostgreSQL”, and pam, “PAM is used to authenticate users, auth_file is ignored.”

They are off unless someone compiled them in. The PgBouncer 1.25.2 installation notes say “To enable LDAP authentication, ./configure has a flag --with-ldap (default value is no)” and “To enable PAM authentication, ./configure has a flag --with-pam (default value is no).”

The build tested here reports no LDAP, PAM or SASL libraries linked, which is why it rejected both. That is a fact about a binary, not about PgBouncer 1.25.2. Check your own:

Terminal window
pgbouncer -V
ldd "$(which pgbouncer)" | grep -Ei 'ldap|pam|sasl'

oauth is different: there is no --with-oauth. It is not a value PgBouncer has in any build.

The error messages also do not distinguish between the accepted values for auth_type and auth_hba_file. The PgBouncer configuration reference documents pam as “not supported in the HBA configuration file”, and peer is an HBA method rather than a global auth_type, which matches the rejection measured above.

Why a pooler cannot transparently forward it

Section titled “Why a pooler cannot transparently forward it”

The claim here is narrower than “cannot support OAuth”. It is about transparency.

A pooler has two separate authentication conversations:

Client ──[ front-end auth ]──> PgBouncer ──[ back-end auth ]──> PostgreSQL

It completes the first before handing you a backend connection, because backend connections are shared and reused. A one-to-one relay of a challenge and response between the two ends is therefore incompatible with connection pooling.

That leaves two designs, and both require more than a parsing change:

Terminate the token at the pooler. Validate the bearer token, map the subject to a database role, then connect onwards with a credential the pooler holds. The pooler becomes the identity provider integration, and the database sees the pooler’s credential rather than the person’s.

Authenticate each backend under the end identity. Then a backend connection belongs to one identity and cannot be reused for another, which removes much of the benefit of pooling.

Neither is impossible. Both trade away either pooling or identity fidelity, and PgBouncer has chosen not to implement either. Other questions remain: what happens to a pooled session when the token expires, how revocation reaches an already-open connection, and whether role membership and row-level security stay correct when a physical connection is reused.

client pathhow the client authenticatesrole PostgreSQL seeswhat you trade
direct to PostgreSQL, OAuthbearer token, validated by your modulethe mapped end identityno pooling; needs a client that can do the flow
direct to PostgreSQL, OAuth, workload tokenbearer token held by the applicationthe mapped workload identityno pooling; token lifetime and refresh are yours
PgBouncer, SCRAM or certpassword or client certificatethe service accountpooling, and the database cannot see who called
identity-aware gateway, then PgBouncerat the gatewaythe service account behind the gatewayas above, plus a component to run
PgBouncer, OAuth frontendnot supported in 1.25.2

The last row is the one this page proves. The others are choices.

People and interactive tools: OAuth, directly

Section titled “People and interactive tools: OAuth, directly”

A DBA with psql, an engineer running a migration, or a reporting tool a human drives. These are the connections where identity matters and where a browser-based flow is acceptable.

Give them a listener that requires TLS and restricts the source, because a bearer token is a credential and the HBA rule is where you decide who may present one:

hostssl all all 10.20.0.0/16 oauth issuer="https://login.example.com" \
scope="openid profile" validator="your_validator" map="oauth_map"

hostssl rather than host, and a network you control rather than 0.0.0.0/0. The broken-file example earlier in this page uses 0.0.0.0/0 because it demonstrates a parse failure; do not copy that line into a server.

The map sends the authenticated subject to a database role through pg_ident.conf, so a person’s identity provider account becomes a role you have granted rather than a role they chose. Verify the mapping is parsed, since a broken map is as silent as a broken rule:

SELECT map_name, sys_name, pg_username, error
FROM pg_ident_file_mappings;

Before configuring this, write down what you are trusting: which authorization server and exact issuer URI, which audience or resource the validator requires, which scopes mean database access, how a subject becomes a role, whether validation is local signature checking or introspection, and what happens when key rotation or the discovery endpoint fails. The HBA line is the last five percent of that.

Applications: service accounts through the pooler

Section titled “Applications: service accounts through the pooler”

Application traffic is a workload, not a person. It connects thousands of times a second, and a browser flow is meaningless for it. Give it a role per workload, not one shared role, with SCRAM or a client certificate:

CREATE ROLE svc_checkout LOGIN PASSWORD 'use-a-generated-secret'
NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT 40;
GRANT CONNECT ON DATABASE app TO svc_checkout;
GRANT USAGE ON SCHEMA public TO svc_checkout;
GRANT SELECT, INSERT, UPDATE ON orders, customers TO svc_checkout;
ALTER ROLE svc_checkout SET statement_timeout = '15s';
ALTER ROLE svc_checkout SET idle_in_transaction_session_timeout = '30s';

One role per workload makes the rest of this useful. It gives you a connection limit you can set per workload, a statement timeout per workload, and an attribution in pg_stat_activity and in workload statistics that tells you which service is responsible for what.

Confirm the roles and their limits:

SELECT rolname, rolconnlimit, rolvaliduntil, rolsuper, rolcreatedb, rolcreaterole
FROM pg_roles
WHERE rolcanlogin
ORDER BY rolname;

rolconnlimit of -1 is unlimited, which is the default and which most roles have.

Then decide the pooler’s two halves, which are separate questions. How clients prove themselves to PgBouncer is auth_type with auth_file, or auth_user and auth_query. How PgBouncer logs in to PostgreSQL is the credential in the database definition. The PgBouncer configuration reference says of auth_query: “Direct access to pg_authid requires admin rights. It’s preferable to use a non-superuser that calls a SECURITY DEFINER function instead.” Take that advice; the default query reads pg_authid directly.

If the pooler runs in transaction mode, the PgBouncer configuration reference is blunt about what the application may not rely on: “clients must not use any session-based features, since each transaction ends up in a different connection and thus gets a different session state.”

That is the same constraint that rules out carrying an end user’s identity in a session GUC through the pooler, which is the workaround people reach for next.

A service account means the database cannot see who called. If every replica of a workload connects as svc_checkout, then current_user, session_user, every role-based row-level security policy and most audit records name svc_checkout. That is a deliberate trade for pooling, and it has to be made before an incident.

Putting the end user in application_name, a session GUC, a SQL comment or a tracing header is useful for attribution and is not an authorization boundary, because the application chose the value. It only becomes one if the database constrains and verifies it.

For a multi-tenant application, the end user’s token authorizes the service, and the service decides what to do with it. Inside the database the tools are row-level security keyed on a session setting the application cannot usefully forge, a per-tenant schema, or an explicit tenant column on every query. Turning every end user into a database role stops working somewhere around the second thousand users. If end-user authorization genuinely has to be enforced by PostgreSQL, a shared service role is the wrong primitive and the architecture has to preserve identity at the database layer instead.

Ask for an OAuth setup for PostgreSQL 18 behind PgBouncer and you may get a configuration with a pg_hba line using oauth, issuer and scope, and a PgBouncer ini with auth_type = oauth. It may read as authoritative while the PgBouncer half cannot start. The PostgreSQL half leaves the entire pg_hba file unloaded if the validator is not configured first, then refuses to start the next time the server restarts.

The second pattern is the missing validator. The pg_hba syntax is documented, so generated configuration uses it, but the syntax does not say that PostgreSQL ships no implementation of the referenced validator.

The third is 0.0.0.0/0 and host, both of which appear in generated examples because they appear in documentation examples. Neither belongs on an authentication rule that accepts bearer tokens.

Check pg_hba_file_rules after every edit, not after every reload. The view reads the file on disk, which is what the next restart will load. A reload that failed leaves no trace in the running configuration.

Keep the validator and the pg_hba line in one change. The rule without the library takes the file down with it: the reload fails quietly and the next restart fails loudly.

Test the pooler configuration in a container before the maintenance window. auth_type = oauth fails in under a second, so a container test catches it before the window.

Check what your PgBouncer binary was built with, not what the documentation lists. ldap and pam are documented and are off by default at build time.

Use one role per workload. It is available today and makes the rest attributable.

  • Can PgBouncer pass PostgreSQL 18 OAuth through to the server?

    No. Measured on PgBouncer 1.25.2, auth_type = oauth is refused at startup with 'invalid value "oauth" for parameter auth_type in configuration' and the config is not loaded. OAuth is not a value PgBouncer has. That rules out one path, a client logging in to the pooler with a bearer token, and it does not rule out using OAuth in a system that contains PgBouncer.

  • Does PostgreSQL 18 ship an OAuth validator?

    No. PostgreSQL 18 ships the OAuth protocol and the extension points, and you supply the validator module. Measured on the official postgres:18.6 image, oauth_validator_libraries is empty and no validator library is installed. The client half is optional too: libpq's built-in Device Authorization flow lives in a separate module that is not installed by default on that image.

  • What happens if I add an oauth line to pg_hba.conf without a validator?

    It depends on whether you reload or restart, and the difference is the trap. Measured on 18.6, a reload logged 'oauth_validator_libraries must be set for authentication method oauth' then 'pg_hba.conf was not reloaded', and the server carried on serving the old rules. The same file on a restart gave 'FATAL: could not load pg_hba.conf' and the server did not start.

  • Does PgBouncer support ldap and pam?

    Both are documented auth_type values, and both are compile-time options that default to off. PgBouncer 1.25.2's install documentation says the configure flag --with-ldap has 'default value is no'. Measured on a 1.25.2 build with no LDAP, PAM or SASL libraries linked, both values were rejected. That is a fact about the build, not about the version.

  • What is the recommended architecture, then?

    Match the door to the client rather than to a rule. People and interactive tools can authenticate with OAuth directly to PostgreSQL, so their database identity is their real identity. Pooled application traffic uses a per-workload service account with SCRAM or a client certificate. An application can also hold a token and connect directly, and an identity-aware gateway in front of the pooler is a third design with different trade-offs.