Using IAM database authentication with async engines

Listen for do_connect on engine.sync_engine and set cparams["password"] to a freshly generated IAM token each time the pool opens a connection — tokens expire in minutes but are only checked at login, so pooled connections keep working while new ones always get a valid credential. This guide belongs to tuning connection pools for cloud databases.

Quick Answer

The tempting version generates a token at startup and puts it in the URL. It works until the first connection opened after the token expires.

A token per new connection Five steps. The pool needs a new connection. SQLAlchemy fires the do_connect event with the connection parameters. The listener returns a cached IAM token, generating a new one if the cached token is older than ten minutes, and sets it as the password. asyncpg connects over TLS and the database validates the token once, at authentication. The connection then stays valid for its whole life, even after the fifteen-minute token lifetime, because tokens are only checked at login. pool needs a connection checkout with no idle connection or a recycle / pre-ping replacement do_connect event cparams["password"] = token cached, refreshed after 10 min asyncpg connects over TLS token validated at login IAM auth requires SSL connection in the pool valid past token expiry Tokens authenticate logins, not sessions: an existing connection is never re-checked.

Before — a token baked into the URL at startup:

import boto3
from sqlalchemy.ext.asyncio import create_async_engine

rds = boto3.client("rds", region_name="eu-west-1")
token = rds.generate_db_auth_token(
    DBHostname="shop.cluster-abc.eu-west-1.rds.amazonaws.com", Port=5432,
    DBUsername="orders_api", Region="eu-west-1",
)
engine = create_async_engine(
    f"postgresql+asyncpg://orders_api:{token}@shop.cluster-abc.eu-west-1.rds.amazonaws.com/shop"
)
# Sixteen minutes later, when the pool opens another connection:
# asyncpg.exceptions.InvalidPasswordError: PAM authentication failed for user "orders_api"

After — a token per new connection, cached for most of its life:

import ssl
import time

import boto3
from sqlalchemy import event
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

HOST = "shop.cluster-abc.eu-west-1.rds.amazonaws.com"
PORT = 5432
USER = "orders_api"
REGION = "eu-west-1"
TOKEN_TTL_S = 600   # refresh well inside the 15-minute validity

_rds = boto3.client("rds", region_name=REGION)
_cached: tuple[float, str] | None = None


def _iam_token() -> str:
    global _cached
    now = time.monotonic()
    if _cached is None or now - _cached[0] > TOKEN_TTL_S:
        token = _rds.generate_db_auth_token(
            DBHostname=HOST, Port=PORT, DBUsername=USER, Region=REGION
        )
        _cached = (now, token)
    return _cached[1]


engine = create_async_engine(
    f"postgresql+asyncpg://{USER}@{HOST}:{PORT}/shop",
    connect_args={"ssl": ssl.create_default_context()},
    pool_size=10,
    pool_pre_ping=True,
    pool_recycle=3600,
)


@event.listens_for(engine.sync_engine, "do_connect")
def _provide_token(dialect, conn_rec, cargs, cparams) -> None:
    cparams["password"] = _iam_token()


Session = async_sessionmaker(engine, expire_on_commit=False)

Download the RDS certificate bundle and load it into the SSLContext with load_verify_locations() if the default trust store does not include Amazon's roots in your image.

Execution Context & Async Workflow Integration

IAM database authentication replaces a long-lived password with a short-lived signed token. On AWS, generate_db_auth_token() produces a SigV4-signed string — computed locally from the process's AWS credentials, with no network call — that the database accepts as the password for a user granted the rds_iam role, for fifteen minutes. The same model appears on Azure, where an Entra ID access token is the password, and on Google Cloud SQL, where the connector library handles tokens for you.

Three clouds, one pattern Three tiles. AWS RDS and Aurora: boto3 generate_db_auth_token signs a token locally, valid for fifteen minutes, used as the password; the database user needs the rds_iam role. Google Cloud SQL: the Cloud SQL Python Connector with enable_iam_auth handles tokens and TLS itself and plugs in through async_creator. Azure Database for PostgreSQL: an Entra ID access token from azure-identity, valid for about an hour, used as the password. AWS RDS / Aurora generate_db_auth_token() 15 min · GRANT rds_iam Google Cloud SQL Cloud SQL Python Connector async_creator, enable_iam_auth Azure PostgreSQL azure-identity access token ≈ 60 min · Entra ID user In every case the password becomes a short-lived credential fetched at connect time, never stored.

The key property is that tokens authenticate logins. PostgreSQL checks the credential once, while establishing the connection, and never again. A pooled connection opened with a token that has since expired keeps working for as long as the connection lives. What fails is the next new connection — from pool growth into overflow, pool_recycle replacing an old connection, pool_pre_ping replacing a dead one, or a failover — if it presents an expired token.

do_connect is the hook that fits that exactly. SQLAlchemy fires it immediately before the driver's connect() call, with the positional and keyword arguments it is about to pass. Changing cparams["password"] changes what asyncpg receives for that one connection. The listener is registered on engine.sync_engine, because pool and connection events live on the synchronous engine the async engine wraps, and it runs synchronously inside SQLAlchemy's connection logic.

Because the listener is synchronous and runs on the event loop thread, what it does matters. AWS token generation is local signing and takes well under a millisecond, so calling it on every connect would be acceptable; caching it for ten minutes makes it essentially free. Azure's DefaultAzureCredential.get_token() can make a network call to an identity endpoint, which would block the loop — cache the token and refresh it before expiry, ideally from a background task, so the listener only ever reads a cached value.

IAM authentication requires TLS; a connection attempt without it is rejected by pg_hba.conf rules before the token is considered. Set ssl in connect_args with a verifying context. Pool settings are otherwise unaffected, and the guidance in setting pool_size and max_overflow for AWS RDS applies unchanged.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
InvalidPasswordError: PAM authentication failed for user "orders_api" minutes after startupToken generated once and reused past its 15-minute validity.Supply the token in do_connect.
The same error immediatelyUser lacks the rds_iam role, IAM auth is disabled on the instance, or the IAM policy lacks rds-db:connect for this user.GRANT rds_iam TO orders_api; and check the policy resource ARN.
The same error only through a reader or custom endpointToken signed for a different hostname than the one connected to.Sign with exactly the host used in the URL.
no pg_hba.conf entry for host ... no encryptionConnecting without TLS.connect_args={"ssl": ssl.create_default_context()}.
Event-loop stalls when the pool growsA token fetch that makes a network call runs inside do_connect on the loop.Cache the token and refresh it in the background.
ClientError: ... security token included in the request is expired from boto3The process's own AWS credentials expired, so it cannot sign new tokens.Use the SDK's refreshing credential providers (instance role, IRSA, ECS task role).
Token at startup, or token per connection Left: the token is generated once and baked into the URL at startup; connections opened in the first fifteen minutes work, and every new connection after that, from pool growth, recycling or pre-ping replacement, fails with PAM authentication failed. Right: the do_connect listener supplies a current token whenever a connection is opened, so pool growth and replacement keep working indefinitely. token in the URL at startup URL built once with the token first 15 minutes: fine pool growth after that fails PAM authentication failed token in do_connect listener sets cparams["password"] fresh or cached-and-valid token recycles and overflow connect fine works for the life of the process The startup version passes every test that runs for less than fifteen minutes.

Two of these are worth expanding.

The hostname row catches setups with several endpoints. The token is a signature over the hostname, port, user and region. A token generated for the cluster writer endpoint is not valid when connecting to the reader endpoint, a custom endpoint or an RDS Proxy endpoint, even for the same user. Engines for read replicas each need their own listener, signing for their own host.

The immediate failure is usually permissions rather than code. Three things must all be true: IAM authentication is enabled on the instance or cluster; the database user exists and has been granted rds_iam (and, once it has, can no longer log in with a password); and the calling principal's IAM policy allows rds-db:connect on arn:aws:rds-db:<region>:<account>:dbuser:<resource-id>/<user>. The resource ID in that ARN is the cluster's or instance's DbiResourceId, not its name, which is the most common policy mistake.

Advanced: Cloud SQL With async_creator, and Azure Tokens

Google's Cloud SQL Python Connector does more than generate tokens: it fetches ephemeral client certificates, establishes the TLS connection to the instance and handles IAM login. It therefore replaces the whole connection step rather than supplying a password, and SQLAlchemy's async_creator parameter is the place to plug it in:

Cost per new connection Bar chart, illustrative. A static password adds nothing. A cached AWS token adds almost nothing. Generating an AWS token uncached adds a small amount of local signing time with no network call. Fetching an Azure token or a Cloud SQL connector refresh can add a network round trip, which caching avoids. static password baseline AWS token, cached a dictionary lookup AWS token, generated local SigV4 signing, no network Azure token fetch, uncached a network call to the identity endpoint Illustrative. Cache tokens for most of their lifetime; the refresh then happens rarely, off the hot path.
from google.cloud.sql.connector import Connector, create_async_connector
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

INSTANCE = "shop-prod:europe-west1:shop-pg"


async def build_engine() -> tuple[AsyncEngine, Connector]:
    connector = await create_async_connector()

    async def getconn():
        return await connector.connect_async(
            INSTANCE,
            "asyncpg",
            user="orders-api@shop-prod.iam",   # service account, without .gserviceaccount.com
            db="shop",
            enable_iam_auth=True,
        )

    engine = create_async_engine(
        "postgresql+asyncpg://",
        async_creator=getconn,
        pool_size=5,
        pool_pre_ping=True,
    )
    return engine, connector


# In the application lifespan:
#   engine, connector = await build_engine()
#   ...
#   await engine.dispose()
#   await connector.close_async()

async_creator is called whenever the pool needs a connection, and because it is a coroutine, the connector's network calls do not block the loop. The URL carries only the dialect and driver; everything else comes from the connector. Create the connector inside the running loop, as above, rather than at import time.

Azure Database for PostgreSQL uses Entra ID access tokens as passwords, valid for roughly an hour. The token fetch can involve a network call, so fetch and refresh it asynchronously and let do_connect read the cached value:

import asyncio
import time

from azure.identity.aio import DefaultAzureCredential
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine

SCOPE = "https://ossrdbms-aad.database.windows.net/.default"


class AzureTokenCache:
    def __init__(self) -> None:
        self._credential = DefaultAzureCredential()
        self.token: str | None = None

    async def refresh_forever(self) -> None:
        while True:
            access = await self._credential.get_token(SCOPE)
            self.token = access.token
            # Refresh five minutes before expiry.
            await asyncio.sleep(max(60, access.expires_on - time.time() - 300))


def install_azure_auth(engine: AsyncEngine, cache: AzureTokenCache) -> None:
    @event.listens_for(engine.sync_engine, "do_connect")
    def _provide_token(dialect, conn_rec, cargs, cparams):
        if cache.token is None:
            raise RuntimeError("Azure token not yet available")
        cparams["password"] = cache.token

Start refresh_forever() as a task in the lifespan and await the first token before the application accepts traffic. The pattern generalises to any provider whose token fetch is slow: refresh in the background, read synchronously at connect time.

IAM Authentication With Proxies and Migrations

Two parts of a deployment connect to the database outside the request path, and both need the same treatment: connection proxies and migration runs.

IAM beyond the application engine Three bands. Through RDS Proxy, the application signs the token for the proxy endpoint and the proxy authenticates upstream with its own secret. Through PgBouncer, clients usually authenticate to PgBouncer with a password while PgBouncer handles upstream credentials. Migration jobs install the same do_connect listener on their own engine, signing as a separate, more privileged migration user. RDS Proxy: sign for the proxy endpoint same listener, different hostname; cache tokens for per-invocation connects PgBouncer: usually no end-to-end IAM clients authenticate to PgBouncer; it holds upstream credentials migrations: same listener, separate privileged user its own rds_iam grant and policy; credentials live only for the job

RDS Proxy supports IAM authentication on its client side, so the application signs a token for the proxy endpoint and the proxy authenticates to the database using credentials from Secrets Manager. The do_connect listener is identical; only the hostname it signs for changes. Because the proxy multiplexes client connections, tokens are validated when the application opens a connection to the proxy — which, with the NullPool pattern used in serverless handlers, is every invocation. Caching the token matters more there, since it removes signing from the per-invocation path entirely.

PgBouncer in front of RDS cannot pass IAM tokens through in the usual configurations: it authenticates clients itself and connects to the database with its own credentials. Applications behind it typically use a password known to PgBouncer while PgBouncer's own upstream connection uses IAM or a managed secret. Check what your proxy supports before designing around end-to-end IAM.

Migrations run with a different engine, usually from a CI job or release step, and need tokens too. An async env.py can install the same listener on the engine it creates, or reuse the application's engine through the shared-connection pattern in running Alembic migrations programmatically from async code:

# alembic/env.py (excerpt)
from sqlalchemy import event, pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from shop.db_auth import iam_token, ssl_context


async def run_async_migrations() -> None:
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
        connect_args={"ssl": ssl_context()},
    )

    @event.listens_for(connectable.sync_engine, "do_connect")
    def _provide_token(dialect, conn_rec, cargs, cparams):
        cparams["password"] = iam_token()

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()

Migration users usually need more privileges than the application user — creating tables, altering types — so they are typically a separate database role with its own rds_iam grant and its own IAM policy statement. Keeping them separate means the application's credentials cannot alter the schema, and the migration job's credentials exist only for the minutes the job runs.

Frequently Asked Questions

Do pooled connections break when the IAM token expires?

No. PostgreSQL validates the token only when a connection is established. Existing connections keep working; only new connections need a valid token, which the do_connect listener supplies.

Is generating an RDS auth token slow?

No. generate_db_auth_token() signs locally with the process's AWS credentials and makes no network call. Caching it for ten minutes is still worthwhile because it removes even that work from the connect path.

How do I use IAM authentication with Cloud SQL and asyncpg?

Use the Cloud SQL Python Connector with connect_async(..., "asyncpg", enable_iam_auth=True) inside an async_creator function passed to create_async_engine("postgresql+asyncpg://", async_creator=...).

Can I use pool_recycle with IAM authentication?

Yes. Recycled connections are new connections, so they go through do_connect and receive a current token. There is no need to align pool_recycle with the token lifetime.