Authentication Proxy¶
This example builds a PostgreSQL proxy that handles authentication on behalf of clients, allowing them to connect without providing credentials.
Overview¶
The authentication proxy acts as a middleman between PostgreSQL clients (like psql) and a real PostgreSQL server:
- Client to Proxy: Clients connect using
trustauthentication (no password required) - Proxy to Server: Proxy authenticates to the real server using MD5 or SCRAM-SHA-256
- Message Forwarding: All messages are decoded, logged, and forwarded
Use cases¶
- Testing. Validate pygwire's codec and state machines for authentication flows.
- Learning. Understand PostgreSQL authentication protocols (MD5, SCRAM-SHA-256, trust).
- Debugging. Inspect all protocol messages with full visibility.
- Middleware. Build authentication layers or connection poolers.
- Security. Centralize database credentials instead of distributing them to clients.
Design¶
The proxy uses pygwire's Connection classes, which coordinate decoding and state machine validation throughout the entire connection lifecycle.
Authentication phase¶
During startup and authentication, the proxy actively participates in the protocol:
- Decodes messages to intercept and handle authentication
- Uses state machines (via Connection classes) to validate protocol flow and catch errors
- Constructs messages to send trust auth to client, real auth to server
flowchart LR
Client["Client<br/>(psql)"]
Proxy["Proxy"]
Server["Server<br/>(Postgres)"]
Client <-->|Trust Auth| Proxy
Proxy <-->|MD5/SCRAM Auth| Server
Client -.->|BackendConnection<br/>validates auth flow| Proxy
Proxy -.->|FrontendConnection<br/>validates auth flow| Server
Query phase¶
After authentication, the same Connection objects continue to decode, validate, log, and forward messages bidirectionally:
flowchart LR
Client["Client<br/>(psql)"]
Proxy["Proxy<br/><br/><i>Decode + Validate + Log</i>"]
Server["Server<br/>(Postgres)"]
Client <--> Proxy
Proxy <--> Server
The Connection classes handle both phases. State machine validation catches protocol errors at any point, and message logging provides full visibility for debugging.
Usage¶
Configuration¶
Configure the proxy using environment variables:
export PROXY_PORT=5433 # Port proxy listens on
export PROXY_SERVER_HOST=localhost # Real PostgreSQL server
export PROXY_SERVER_PORT=5432 # Real server port
export PROXY_SERVER_SSL=true # Use SSL to server
export PROXY_SERVER_USER=myuser # Server username
export PROXY_SERVER_PASSWORD=mypassword # Server password
export PROXY_SERVER_DATABASE=mydb # Server database
Running the proxy¶
Output:
11:06:54 [INFO] Proxy listening on ('0.0.0.0', 5433)
11:06:54 [INFO] Forwarding to PostgreSQL at localhost:5432
11:06:54 [INFO] Server: SSL=True, User=myuser, DB=mydb
11:06:54 [INFO] Clients will use trust auth, proxy will authenticate to server
11:06:54 [INFO] Press Ctrl+C to stop
Connecting through the proxy¶
Connect with any PostgreSQL client without providing credentials:
# psql (no password needed)
psql -h localhost -p 5433 -U anyuser mydb
# Python with psycopg2
import psycopg2
conn = psycopg2.connect(
host="localhost",
port=5433,
user="anyuser",
database="mydb"
# No password
)
Example session¶
$ psql -h localhost -p 5433 -U testuser testdb
psql (15.16, server 15.12)
Type "help" for help.
testdb=> SELECT version();
version
─────────────────────────────────────────────────────────────────────────
PostgreSQL 15.12 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 11.2.0
(1 row)
testdb=> \q
Proxy logs show all protocol messages:
11:07:00 [INFO] [127.0.0.1:65244] New connection from ('127.0.0.1', 65244)
11:07:00 [INFO] [127.0.0.1:65244] Server SSL response: SUPPORTED
11:07:00 [INFO] [127.0.0.1:65244] SSL handshake complete
11:07:00 [INFO] [127.0.0.1:65244] Server authenticated!
11:07:00 [INFO] [127.0.0.1:65244] Client startup: user=testuser, db=testdb
11:07:00 [INFO] [127.0.0.1:65244] Client authenticated with trust auth
11:07:05 [INFO] [127.0.0.1:65244] → Query (query="SELECT version();...")
11:07:05 [INFO] [127.0.0.1:65244] ← RowDescription
11:07:05 [INFO] [127.0.0.1:65244] ← DataRow
11:07:05 [INFO] [127.0.0.1:65244] ← CommandComplete (tag=SELECT 1)
11:07:05 [INFO] [127.0.0.1:65244] ← ReadyForQuery (status=IDLE)
Implementation details¶
Key components¶
AsyncFrontendConnection / AsyncBackendConnection: Async wrappers around pygwire's FrontendConnection and BackendConnection classes that add:
- Async I/O via
asyncio.StreamReader/asyncio.StreamWriter on_send()hook to automatically write to the streamrecv_messages()async generator for reading and decoding messages- State machine tracking built in via the
Connectionbase class
ProxyConnection: Manages a single client connection:
- Connects and authenticates to real PostgreSQL server
- Handles client startup with
trustauthentication - Proxies messages bidirectionally with decoding and logging
SSL negotiation¶
The proxy supports SSL/TLS connections to the backend server:
async def _negotiate_ssl(self, conn: AsyncFrontendConnection) -> None:
"""Negotiate SSL/TLS with the server using the connection."""
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Send SSL request through connection (updates state machine)
await conn.send_message(messages.SSLRequest())
# Receive SSL response through connection (decoder handles it)
msg = await conn.recv_next_message()
if isinstance(msg, messages.SSLResponse):
logger.info(
f"[{self.connection_id}] Server SSL response: {'accepted' if msg.accepted else 'rejected'}"
)
if not msg.accepted:
raise RuntimeError("Server does not support SSL")
else:
raise RuntimeError(f"Expected SSLResponse, got {type(msg).__name__}")
# Upgrade to TLS
assert self.server_writer is not None
await self.server_writer.start_tls(ssl_context, server_hostname=self.server_host)
logger.info(f"[{self.connection_id}] SSL handshake complete")
Authentication¶
The proxy supports multiple authentication methods when connecting to the backend server.
MD5 Password Authentication:
async def _authenticate_md5(
self, msg: messages.AuthenticationMD5Password, conn: AsyncFrontendConnection
) -> None:
"""Handle MD5 password authentication."""
logger.info(f"[{self.connection_id}] Server requesting MD5 password")
if not self.server_password:
raise RuntimeError("No password provided for MD5 auth")
user = self.server_user or "postgres"
md5_password = compute_md5_password(self.server_password, user, msg.salt)
pwd_msg = messages.PasswordMessage(password=md5_password)
await conn.send_message(pwd_msg)
SCRAM-SHA-256 Authentication:
async def _authenticate_scram_start(
self, msg: messages.AuthenticationSASL, conn: AsyncFrontendConnection
) -> dict[str, str]:
"""Start SCRAM-SHA-256 authentication."""
logger.info(f"[{self.connection_id}] Server requesting SASL auth: {msg.mechanisms}")
if "SCRAM-SHA-256" not in msg.mechanisms:
raise RuntimeError("Only SCRAM-SHA-256 is supported")
if not self.server_password:
raise RuntimeError("No password provided for SCRAM")
user = self.server_user or "postgres"
client_first, client_first_bare, nonce = build_scram_client_first(user)
sasl_msg = messages.SASLInitialResponse(
mechanism="SCRAM-SHA-256", data=client_first.encode("utf-8")
)
await conn.send_message(sasl_msg)
return {
"username": user,
"password": self.server_password,
"client_nonce": nonce,
"client_first_bare": client_first_bare,
}
The Connection classes validate the protocol flow via their built-in state machines throughout the entire connection lifecycle.
Message forwarding¶
After authentication, messages continue to be decoded, validated, and logged as they are forwarded:
async def _proxy_client_to_server(self) -> None:
"""Proxy messages from client to server (frontend messages)."""
try:
while True:
has_messages = False
try:
async for msg in self.client_conn.recv_messages():
has_messages = True
await self._handle_frontend_message(msg)
# Forward message to server
if self.server_conn:
await self.server_conn.send_message(msg)
except Exception as e:
logger.error(f"[{self.connection_id}] Error decoding frontend message: {e}")
break
if not has_messages:
logger.info(f"[{self.connection_id}] Client disconnected")
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"[{self.connection_id}] Client->Server proxy error: {e}", exc_info=True)
async def _proxy_server_to_client(self) -> None:
"""Proxy messages from server to client (backend messages)."""
try:
assert self.server_conn is not None
while True:
has_messages = False
try:
async for msg in self.server_conn.recv_messages():
has_messages = True
await self._handle_backend_message(msg)
# Forward message to client
await self.client_conn.send_message(msg)
except Exception as e:
logger.error(f"[{self.connection_id}] Error decoding backend message: {e}")
break
if not has_messages:
logger.info(f"[{self.connection_id}] Server disconnected")
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"[{self.connection_id}] Server->Client proxy error: {e}", exc_info=True)
The Connection classes coordinate decoding and state machine validation automatically, so the proxy code only needs to log and forward.
Source code¶
The complete source code is available at examples/auth_proxy.py.