Postgres 18 OAuth through PgBouncer and service accounts
Quick answer: A client cannot log in to PgBouncer with an OAuth token. Version 1.25.2 has no
oauthvalue forauth_typeand 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.
What is the problem?
Section titled “What is the problem?”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.
What is actually there on PostgreSQL 18
Section titled “What is actually there on PostgreSQL 18”One server setting, and four HBA options
Section titled “One server setting, and four HBA options”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 | | sighupEmpty, 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 option | what 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 directoryls /lib/.../libcurl* -> No such file or directorydpkg -s libpq5 | grep Suggests -> Suggests: libpq-oauthThe 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 oauthCONTEXT: line 129 of configuration file ".../pg_hba.conf"LOG: .../pg_hba.conf was not reloadedThe 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 oauthCONTEXT: line 129 of configuration file ".../pg_hba.conf"FATAL: could not load /var/lib/postgresql/18/docker/pg_hba.confThe 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, errorFROM pg_hba_file_rulesWHERE 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 oauthThe 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.
What PgBouncer does with it
Section titled “What PgBouncer does with it”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 fileThe 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_FULLoauth, 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:
pgbouncer -Vldd "$(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 ]──> PostgreSQLIt 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.
The shapes that work, side by side
Section titled “The shapes that work, side by side”| client path | how the client authenticates | role PostgreSQL sees | what you trade |
|---|---|---|---|
| direct to PostgreSQL, OAuth | bearer token, validated by your module | the mapped end identity | no pooling; needs a client that can do the flow |
| direct to PostgreSQL, OAuth, workload token | bearer token held by the application | the mapped workload identity | no pooling; token lifetime and refresh are yours |
PgBouncer, SCRAM or cert | password or client certificate | the service account | pooling, and the database cannot see who called |
| identity-aware gateway, then PgBouncer | at the gateway | the service account behind the gateway | as above, plus a component to run |
| PgBouncer, OAuth frontend | not supported in 1.25.2 |
The last row is the one this page proves. The others are choices.
The architecture that works now
Section titled “The architecture that works now”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, errorFROM 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, rolcreateroleFROM pg_rolesWHERE rolcanloginORDER 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.
Transaction pooling is not free either
Section titled “Transaction pooling is not free either”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.
Where end-user identity belongs
Section titled “Where end-user identity belongs”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.
Why does AI-generated code cause this?
Section titled “Why does AI-generated code cause this?”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.
How do I stop it coming back?
Section titled “How do I stop it coming back?”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.