Skip to content

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:

  1. Client to Proxy: Clients connect using trust authentication (no password required)
  2. Proxy to Server: Proxy authenticates to the real server using MD5 or SCRAM-SHA-256
  3. 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

python examples/auth_proxy.py

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 stream
  • recv_messages() async generator for reading and decoding messages
  • State machine tracking built in via the Connection base class

ProxyConnection: Manages a single client connection:

  • Connects and authenticates to real PostgreSQL server
  • Handles client startup with trust authentication
  • Proxies messages bidirectionally with decoding and logging

SSL negotiation

The proxy supports SSL/TLS connections to the backend server:

examples/auth_proxy.py
    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:

examples/auth_proxy.py
    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:

examples/auth_proxy.py
    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:

examples/auth_proxy.py
    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.

Further reading