Preview PostgreSQL 18’s OAuth2 Authentication (2) - Building a Custom OAuth2 Validator by Rust

September 17, 2025

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 (not oauth2) and requires issuer and scope 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:

  1. Safety first: Use unsafe blocks only at C-Rust boundaries and implement robust error handling.
  2. Modular design: Separate concerns between C ABI implementation and business logic.
  3. Memory management: Use PostgreSQL's allocator for data returned to C.
  4. Error handling: Implement comprehensive error handling and logging.
  5. Testing: Thoroughly test with real OAuth2 tokens across different scenarios.

Further Readings

Share this