# 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 `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.

## 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](https://www.postgresql.org/docs/18/auth-oauth.html)
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](https://www.postgresql.org/docs/18/auth-oauth.html)
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

### One server setting, and four HBA options

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

```sql
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](https://www.postgresql.org/docs/18/auth-oauth.html)
gives each option its own requirement:

| HBA option | what the [PostgreSQL 18 OAuth documentation](https://www.postgresql.org/docs/18/auth-oauth.html) 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](https://www.postgresql.org/docs/18/auth-oauth.html)
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](https://www.postgresql.org/docs/18/auth-oauth.html):
"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

The build can look like it supports OAuth when it does not at runtime. The
[PostgreSQL 18 libpq OAuth documentation](https://www.postgresql.org/docs/18/libpq-oauth.html)
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](https://www.postgresql.org/docs/18/libpq-oauth.html),
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

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:

```sql
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.

## 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 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

Both are documented. The
[PgBouncer configuration reference](https://www.pgbouncer.org/config.html)
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](https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_25_2/README.md)
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:

```sh
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](https://www.pgbouncer.org/config.html)
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

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.

## 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

### 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:

```sql
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

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:

```sql
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:

```sql
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](https://www.pgbouncer.org/config.html) 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

If the pooler runs in transaction mode, the
[PgBouncer configuration reference](https://www.pgbouncer.org/config.html) 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

**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?

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?

**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.

## How DBGorilla helps

Service accounts are only useful if you can see what each one did. DBGorilla
connects read-only and gives your AI coding agent the workload attributed to the
user that ran it, with duration, invocations and rows per query fingerprint,
plus connection and session snapshots showing state and wait per user. That is
what turns "one role per workload" from a convention into an answer to which
workload is responsible. It surfaces and explains; it does not write to your
database.
[Get started free →](https://app.dbgorilla.com/signup)

## Related

- [How to set up a read-only Postgres role for an AI agent](https://www.dbgorilla.com/learn/postgres/how-to-set-up-a-read-only-postgres-role-for-an-ai-agent/)
- [Does PgBouncer transaction pooling break prepared statements?](https://www.dbgorilla.com/learn/postgres/does-pgbouncer-transaction-pooling-break-prepared-statements/)
- [PostgreSQL connection pooling and too many connections](https://www.dbgorilla.com/learn/postgres/postgresql-connection-pooling-too-many-connections/)