PostgreSQL 18 (RC1 was released on 09/05/2025) introduces a significant new feature: OAuth2 authentication. This lets PostgreSQL authenticate users using OAuth2 tokens instead of traditional username/password pairs. In this guide, we'll walk through building a custom validator module using Rust and the pgrx framework.
This blog series contains 3 articles:
Part-1: Explore how PostgreSQL 18 OAuth2 authentication works
Part-2: (this article) Write a custom validator with Rust
Part-3: Teach a PostgreSQL proto 3 client library to speak OAUTHBEARER
Understanding PostgreSQL 18 OAuth2 Authentication
PostgreSQL 18's OAuth2 support uses a plugin architecture where custom validator modules can be loaded to validate OAuth2 tokens. A validator module must implement a specific C ABI (Application Binary Interface) that PostgreSQL calls during authentication.
The key components are:
- Validator Module: A shared library that implements the OAuth2 validation logic
- OAuth2 Client: Applications that obtain OAuth2 tokens from an identity provider
- Identity Provider: An OAuth2 server (for example, Keycloak, Auth0, or Azure Entra ID) that issues tokens
Building PostgreSQL 18 from Source
Prerequisites
First, install the required build tools on macOS:
brew install gcc icu4c readline zlib curl ossp-uuid pkg-config
Environment Setup
Set up the necessary environment variables:
export OPENSSL_PATH=$(brew --prefix openssl)
export CMAKE_PREFIX_PATH=$(brew --prefix icu4c)
export PATH="$OPENSSL_PATH/bin:$CMAKE_PREFIX_PATH/bin:$PATH"
export LDFLAGS="-L$OPENSSL_PATH/lib $LDFLAGS"
export CPPFLAGS="-I$OPENSSL_PATH/include $CPPFLAGS"
export PKG_CONFIG_PATH="$CMAKE_PREFIX_PATH/lib/pkgconfig"
Download and Build
1. Download PostgreSQL 18 rc1 to a local directory, i.e., ${HOME}/pg
mkdir -p ${HOME}/pg
cd ${HOME}/pg
# download PostgreSQL rc1
wget https://ftp.postgresql.org/pub/source/v18rc1/postgresql-18rc1.tar.gz
tar -xzf postgresql-18rc1.tar.gz
cd postgresql-18rc1
2. Configure and build:
export PG_INST_DIR_PREFIX=${HOME}/pg/pgsql18
./configure --prefix=${PG_INST_DIR_PREFIX} --with-openssl --with-libcurl --without-icu
make -j$(nproc)
make install
3. Add PostgreSQL to your PATH:
export PATH=${PG_INST_DIR_PREFIX}/bin:$PATH
# ensure we are now using pg_config we just built
pg_config --version
# it should output "PostgreSQL 18rc1"
Setting Up a pgrx Project
pgrx is a Rust framework for building PostgreSQL extensions. It provides a safe interface to PostgreSQL's C API.
Install pgrx
cargo install --locked cargo-pgrx
cargo pgrx init --pg18 $(which pg_config)
Create a New Project
cd $HOME/pg
cargo pgrx new my_validator
cd my_validator
Configure Cargo.toml
[package]
publish = false
name = "my_validator"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
name = "my_validator"
[profile.dev]
panic = "abort"
[features]
default = ["pg18"]
pg18 = []
[dependencies]
base64 = "0.22.1"
cc = "1.2.32"
chrono = "0.4"
jsonwebtoken = "9.3.1"
lazy_static = "1.5.0"
reqwest = { version = "0.12.22", features = ["blocking", "json"] }
serde = "1.0.219"
serde_json = "1.0.142"
pgrx = { version = "0.16.0", features = ["pg18"]}
parking_lot = "0.12.4"
[build-dependencies]
bindgen = "0.72.0"
cc = "1.2.32"
Note that we use bindgen
to generate the PostgreSQL FFI (Foreign Function Interface) bindings for Rust. The validator module targets PostgreSQL 18 (the pg18
feature).
Implementing the Custom Validator Module
In this tutorial, we'll build a deliberately simple (and somewhat silly) validator that, when configured, allows all authentication requests during daytime. The example is purely educational and MUST NOT be used in production.
Step 1: Create the Header File
First, create include/pg_oauth_shim.h
to define the OAuth2 validator ABI:
#include "postgres.h" // general Postgres headers
#include "libpq/oauth.h" // Postgres 18 validator ABI
#include "utils/palloc.h" // pstrdup/palloc memory interface
#include "fmgr.h" // Postgres function manager ABI
Step 2: Implement the Build Script
Create build.rs
to generate Rust bindings from the C headers:
use std::{env, path::PathBuf, process::Command};
fn main() {
// Get Postgres include directory by pg_config
let pg_config = env::var("PG_CONFIG").unwrap_or_else(|_| "pg_config".into());
let includedir = cmd_out(&pg_config, &["--includedir"]).trim().to_string();
let includedir_server = cmd_out(&pg_config, &["--includedir-server"])
.trim()
.to_string();
// Rebuild when shim header changes
println!("cargo:rerun-if-changed=include/pg_oauth_shim.h");
// Generate bindings
let bindings = bindgen::Builder::default()
.header("include/pg_oauth_shim.h")
.clang_args([format!("-I{includedir}"), format!("-I{includedir_server}")])
.allowlist_type("OAuthValidatorCallbacks")
.allowlist_type("ValidatorModuleResult")
.allowlist_var("PG_OAUTH_VALIDATOR_MAGIC")
.allowlist_function("pstrdup")
.generate()
.expect("Unable to generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs");
bindings
.write_to_file(out)
.expect("Couldn't write bindings!");
}
fn cmd_out(bin: &str, args: &[&str]) -> String {
let out = Command::new(bin)
.args(args)
.output()
.expect("run pg_config");
String::from_utf8(out.stdout).expect("utf8")
}
Note that the include/pg_oauth_shim.h
is used to tell bindgen to generate the necessary PostgreSQL FFI.
Step 3: Core Implementation
The main implementation in src/lib.rs
contains several key components:
C ABI Implementation
The module must implement three C functions that PostgreSQL calls:
1. startup(): called once when the module is loaded. It typically initializes globally shared configuration data and any caches used by the validation logic (for example, JSON Web Keys).
extern "C" fn startup(state: *mut ValidatorModuleState) {
// Implementation details...
}
2. shutdown(): called once when the module is unloaded. It should clean up module resources and free the memory allocated for the global shared data created in startup()
.
extern "C" fn shutdown(state: *mut ValidatorModuleState) {
// Implementation details...
}
3. validate(): validates OAuth2 tokens on every authentication request.
extern "C" fn validate(
state: *const ValidatorModuleState,
token_ptr: *const ::std::os::raw::c_char,
role_ptr: *const ::std::os::raw::c_char,
result: *mut ValidatorModuleResult,
) -> bool {
// Implementation details...
}
The globally shared module state is defined by ValidatorModuleState
, from the Rust view it is:
pub struct ValidatorModuleState {
pub sversion: ::std::os::raw::c_int,
pub private_data: *mut ::std::os::raw::c_void,
}
The private_data
field is a void *
that points to validator-specific data allocated during startup()
. It should be set to null
by shutdown()
after the data is deallocated.
Module State's Lifecycle
Our example validator defines a global configuration structure that reads a single boolean parameter from the environment variable PGOAUTH_ALLOW_ALL_AT_DAYTIME
.
struct Config {
// a funny config to allow all login during day time
allow_all_at_daytime: bool,
}
impl Config {
fn new() -> Result<Self, String> {
let allow_all_at_daytime = std::env::var("PGOAUTH_ALLOW_ALL_AT_DAYTIME")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Config {
allow_all_at_daytime: allow_all_at_daytime,
})
}
}
So the startup()
function creates this Config
and stores it in the module state's private_data
.
extern "C" fn startup(state: *mut ValidatorModuleState) {
pgrx::info!("my validator startup");
if state.is_null() {
return;
}
match Config::new() {
Ok(module_state) => unsafe {
let boxed = Box::new(module_state);
(*state).private_data = Box::into_raw(boxed) as *mut c_void;
},
Err(e) => {
// fail to init: log it,but not FATAL to avoid let PG goto recovery mode :sob:
pgrx::warning!("my validator startup: {}", e);
// private_data remains null, which is handled gracefully in other functions
}
}
}
We must use unsafe blocks when accessing data through raw pointers (state: *mut ValidatorModuleState
) coming from C. The module_state
should be allocated on the heap (via Box::new()
) and its raw pointer (via Box::into_raw()
) stored in (*state).private_data
so Rust does not deallocate it prematurely.
Cleanup happens in the shutdown()
callback. Using Box::from_raw()
recovers the boxed memory so Rust will deallocate it during module unload, avoiding leaks. The same unsafe{}
pattern applies when accessing (*state).private_data
from the C side.
extern "C" fn shutdown(state: *mut ValidatorModuleState) {
pgrx::info!("my validator shutdown");
unsafe {
if !(*state).private_data.is_null() {
let _boxed: Box<Config> = Box::from_raw((*state).private_data as *mut Config);
(*state).private_data = ptr::null_mut();
}
}
}
Validation
There are 3 tasks the validate()
callback should implement:
1. extract parameters from unsafe C world
2. major validation logic
3. set result and return
They can be summarized as:
extern "C" fn validate(
state: *const ValidatorModuleState,
token_ptr: *const ::std::os::raw::c_char, // we don't use token in this dumb example
role_ptr: *const ::std::os::raw::c_char,
result: *mut ValidatorModuleResult,
) -> bool {
// task-1: extract per-authentication parameters into Rust
let (config, token, role) = match unsafe { get_args(state, token_ptr, role_ptr, result) } {
Ok(args) => args,
Err(_) => return false,
};
// task-2: run your validation logic in safe Rust
let (authorized, authn_id) = safe_validate(config, &token, &role);
// task-3: write the result back into the C structures
unsafe { set_result(result, authorized, authn_id) };
// Internal errors should return 'false'; this example returns 'true' for simplicity
true
}
Task-1 and Task-3 could focus on building a safe C-Rust and Rust-C boundary more, to make the safe_validate()
feel safe to implement the core validation logic.
Task-1 Extract Parameters from C
Let's visit Task-1, which focuses on operating in the unsafe world:
unsafe fn get_args(
state: *const ValidatorModuleState,
token_ptr: *const ::std::os::raw::c_char,
role_ptr: *const ::std::os::raw::c_char,
result: *mut ValidatorModuleResult,
) -> Result<(&'static Config, String, String), ()> {
// Validate all input parameters are non-null
if state.is_null() || result.is_null() || role_ptr.is_null() || token_ptr.is_null() {
pgrx::warning!("my validator: null state or result pointer");
return Err(());
}
// Check private_data is initialized
if (*state).private_data.is_null() {
pgrx::warning!("my validator: null private_data");
return Err(());
}
// Initialize result structure
(*result).authorized = false;
(*result).authn_id = ptr::null_mut();
// Extract configuration
let config = &*((*state).private_data as *const Config);
// ... ...
// ... ...
// Extract role parameter safely
let role_cstr = CStr::from_ptr(role_ptr);
let role = role_cstr
.to_str()
.map_err(|_| {
pgrx::warning!("my validator: invalid UTF-8 in role parameter");
})?
.to_string();
Ok((config, token, role))
}
Checking null
pointer is always a good idea. Initializing result
to unauthorized is a safe default. Converting C strings to Rust uses CStr::from_ptr()
and requires careful handling of potential UTF-8 errors.
Task-2 Validation in Rust
Here's the intentionally simple validation logic:
fn safe_validate(config: &Config, _token: &str, role: &str) -> (bool, Option<String>) {
// allow all only during daytime if enabled in config
let authorized = if config.allow_all_at_daytime {
let hour = chrono::Local::now().hour();
hour < 19 || hour > 6
} else {
pgrx::warning!("my validator: declining login because daytime allowance is disabled");
false
};
// Return authorization result and {role}_at_day as authn_id
(authorized, Some(format!("{}_at_day", role)))
}
The example checks the current time and authorizes a user only when the validator is configured to allow all requests during daytime. This validation logic makes no real-world security sense and is for demonstration only. The function returns an authentication ID of the form role_name_at_day
; PostgreSQL then maps that identity to a database role using pg_ident.conf
as configured from pg_hba.conf
.
Task-3 Result Setting to C
unsafe fn set_result(
result: *mut ValidatorModuleResult,
authorized: bool,
authn_id: Option<String>,
) {
(*result).authorized = authorized;
if let Some(authentication_id) = authn_id {
match CString::new(authentication_id) {
Ok(c_string) => {
(*result).authn_id = pstrdup(c_string.as_ptr());
}
Err(_) => {
pgrx::warning!(
"my validator: authentication ID contains null bytes, setting to null"
);
(*result).authn_id = ptr::null_mut();
}
}
} else {
(*result).authn_id = ptr::null_mut();
}
}
The pg_sys::pstrdup()
call allocates memory using PostgreSQL's allocator (palloc
), ensuring the string lives in the correct memory context and will be freed appropriately at query end.
Register the Callbacks
Register the callbacks with:
#[unsafe(no_mangle)]
pub extern "C" fn _PG_oauth_validator_module_init() -> *mut OAuthValidatorCallbacks {
let callbacks = Box::new(OAuthValidatorCallbacks {
magic: PG_OAUTH_VALIDATOR_MAGIC,
startup_cb: Some(startup),
shutdown_cb: Some(shutdown),
validate_cb: Some(validate),
});
Box::into_raw(callbacks)
}
Building and Installing the Module
Build the module
PG_CONFIG=$(which pg_config) cargo build --release
Install the Module
cp target/release/libmy_validator.dylib $(pg_config --pkglibdir)/my_validator.dylib
Note: on macOS, the suffix for dynamic libraries is
.dylib
.
Configuring PostgreSQL for OAuth2
Create a new PostgreSQL database cluster by
initdb -D my_data
1. Configure pg_hba.conf
Add the OAuth authentication method to my_data/pg_hba.conf
:
# OAuth2 authentication
host all all 0.0.0.0/0 oauth map=my_map issuer=http://your-oauth-server/auth scope="openid profile"
For testing purposes only, this example disables local network login.
# IPv4 local connections:
#host all all 127.0.0.1/32 trust
# IPv6 local connections:
#host all all ::1/128 trust
Note: The authentication method is
oauth
(notoauth2
) and requiresissuer
andscope
arguments.
2. Configure pg_ident.conf
Map OAuth2 identities to PostgreSQL roles in my_data/pg_ident.conf
:
# MAPNAME SYSTEM-USERNAME DATABASE-USERNAME
my_map /^(.+)_at_day$ \1
This simple example maps any OAuth identity (captured by (.+)
) to the same PostgreSQL role name (\1
). For instance, if the validator returns authn_id
as joe_at_day
(we return <role name>_at_day
from safe_validate()
), it maps to PostgreSQL role joe
.
3. Set Environment Variables
Configure the validator module by setting the environment variable:
export PGOAUTH_ALLOW_ALL_AT_DAYTIME="true"
4. Load the Module
Add the following to postgresql.conf
:
oauth_validator_libraries = 'my_validator'
Testing with psql
1. Start PostgreSQL
pg_ctl -D my_data -l logfile start
2. Create Test Role and Database
Use psql postgres
to connect to the just-created instance, and create a user.
CREATE ROLE joe WITH LOGIN;
CREATE DATABASE my_db OWNER joe;
3. Test OAuth2 Connection
PGOAUTHDEBUG=UNSAFE psql "postgres://joe@localhost:5432/my_db?oauth_issuer=http://your-oauth-server/auth&oauth_client_id=your-client-id" -c "SELECT current_user, session_user, version()"
Note:
PGOAUTHDEBUG=UNSAFE
allows using HTTP (no TLS) for local IdP testing and enables verbose tracing of PostgreSQL's OAuth2 authentication flow.
You should see something like:
1. a prompt with the IdP authorization URL and a device code:
Visit http://your-oauth-server/auth/device and enter the code: RJJZ-PRDC
2. Visit that URL and enter the displayed code.
3. Complete the OAuth2 authentication flow.
Eventually, you should see output similar to:
current_user | session_user | version
--------------+--------------+--------------------------------------------------------------------------------------------------------------------
joe | joe | PostgreSQL 18rc1 on aarch64-apple-darwin24.6.0, compiled by Apple clang version 17.0.0 (clang-1700.3.19.1), 64-bit
(1 row)
4. Test at Daytime
Since this example validator allows authentication during daytime, you can either test the same login at daytime or disable the behavior by
export PGOAUTH_ALLOW_ALL_AT_DAYTIME="false"
pg_ctl -D my_data -l logfile restart
Then use the psql
command above to attempt login again.
psql: error: connection to server at "localhost" (127.0.0.1), port 5432 failed: retrying connection with new bearer token
connection to server at "localhost" (127.0.0.1), port 5432 failed: FATAL: OAuth bearer authentication failed for user "joe"
If you check the PostgreSQL log for this cluster, you should see entries similar to:
[73895] WARNING: my validator: decline the login during day time or your system administrator never wants you to login
[73895] LOG: OAuth bearer authentication failed for user "joe"
[73895] DETAIL: Validator failed to authorize the provided token.
[73895] FATAL: OAuth bearer authentication failed for user "joe"
Conclusion
Building a custom OAuth2 validator in Rust for PostgreSQL 18 demonstrates the flexibility of PostgreSQL's plugin architecture. Key takeaways:
- Safety first: Use unsafe blocks only at C-Rust boundaries and implement robust error handling.
- Modular design: Separate concerns between C ABI implementation and business logic.
- Memory management: Use PostgreSQL's allocator for data returned to C.
- Error handling: Implement comprehensive error handling and logging.
- Testing: Thoroughly test with real OAuth2 tokens across different scenarios.