The DSQL Book

Chapter 7 of 17 · The DSQL Book

Connections and IAM tokens

Aurora DSQL closes every connection after one hour and authenticates with a token that expires in fifteen minutes. How to write a pool that survives both, in a Lambda or anywhere else.

The DSQL Book · October 18, 2026 · Written by a person

Aurora DSQL caps a connection at one hour, and authenticates it with a token that expires sooner than that. Those two facts interact badly with the way most pools are written, and the failure arrives on a schedule: the first deploy works, and about fifteen minutes later every new connection is refused.

The token is not a password

There is no password. There is an IAM identity, and a signed token that @aws-sdk/dsql-signer derives from it. Minting one is a local signature over the cluster hostname, not a call to the cluster, and the result is good for fifteen minutes by default.

import { DsqlSigner } from '@aws-sdk/dsql-signer';

const signer = new DsqlSigner({
  hostname: process.env.DSQL_CLUSTER_ENDPOINT,
  region: 'us-east-2',
});
const token = await signer.getDbConnectAdminAuthToken(); // for the admin role

The same thing from a shell, for psql:

export PGPASSWORD=$(aws dsql generate-db-connect-admin-auth-token \
  --hostname "$DSQL_CLUSTER_ENDPOINT" --region us-east-2)
psql -h "$DSQL_CLUSTER_ENDPOINT" -U admin -d postgres

The mistake is to mint one token at startup and hand it to the pool as a string. That works until the token expires. Connections the pool already holds keep working, because the token is only checked at connect time, so nothing looks wrong until the pool needs a new connection. Then every new connection is refused, and the process that has been running fine for an hour starts failing from the inside.

pg has had the fix for years. password may be a function, and the pool calls it once per new connection:

import { DsqlSigner } from '@aws-sdk/dsql-signer';
import { Pool } from 'pg';

let cachedToken: string | null = null;
let tokenExpiresAt = 0;

// Tokens are valid for 15 minutes. Refresh at 10 for headroom.
const TOKEN_TTL_MS = 10 * 60 * 1000;

const getAuthToken = async (): Promise<string> => {
  const now = Date.now();
  if (cachedToken && now < tokenExpiresAt) return cachedToken;
  const signer = new DsqlSigner({
    hostname: process.env.DSQL_CLUSTER_ENDPOINT,
    region: process.env.AWS_REGION || 'us-east-2',
  });
  cachedToken = await signer.getDbConnectAdminAuthToken();
  tokenExpiresAt = now + TOKEN_TTL_MS;
  return cachedToken;
};

export const pool = new Pool({
  host: process.env.DSQL_CLUSTER_ENDPOINT,
  port: 5432,
  user: 'admin',
  database: 'postgres',
  ssl: { rejectUnauthorized: true },
  password: getAuthToken,        // a function, not a string
  max: 6,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 10_000,
});

// A connection the server closes is removed from the pool and replaced on
// the next query. Without this handler that close is an unhandled 'error'
// event, which in Node is a crash.
pool.on('error', () => {});

Cache the token for less than its lifetime. The cache is not about cost, since signing is cheap. It is about not racing the expiry: a token minted at minute fourteen and used at minute sixteen is a refused connection.

One hour, then the server hangs up

A DSQL connection lives at most sixty minutes. Not sixty idle minutes; sixty minutes total. A Lambda container that stays warm through an afternoon will have its connections closed under it, and so will any long-running process.

With the pool above, that is a non-event. The closed client raises error, the pool discards it, and the next query opens a replacement with a freshly minted token. That last part is why the password has to be a function: the replacement connection is made an hour after cold start, and a token from cold start is forty-five minutes dead by then.

A pool, not a client

pg.Client cannot multiplex. A second query() while one is in flight is unsupported, and what you get is not an error but interleaved results: the rows from one query arriving in the other's await. This site fetches a page and its comments concurrently on every render, so with a shared Client it rendered the wrong thing at random under load. A Pool with a handful of connections is the fix, and it is the fix even for an application that only ever runs one query at a time, because that stops being true the first time someone adds Promise.all.

Sizing for Lambda

Every warm container holds its own pool, so max is per container, not per application. The cluster-side limits as of October 2026:

Limit Value
Connections per cluster 10,000
New connections per second 100, with a burst of 1,000
Connection duration 60 minutes

A deploy that cold-starts five hundred containers at once, each opening one connection on its first query, is inside the burst. Five hundred containers each eagerly opening six is not, and the ones past the limit get 53400, "unable to accept connection, rate exceeded". Let the pool open connections lazily, which it does by default, and keep max small. Six is already generous for a site; two or three is enough for a function that runs one query per invocation.

A cluster that has been asleep

A cluster with no traffic for a while is not always ready for the first connection that arrives. The first connect after an idle period can be refused with "unable to accept connection, waking up cluster", and it clears itself within a few seconds. The request that happened to arrive first should not fail because of it:

const isWaking = (err: any) => /waking up cluster/i.test(err?.message || '');

export const query = async (text: string, values?: any[]) => {
  for (let attempt = 0; ; attempt += 1) {
    try {
      return await pool.query({ text, values });
    } catch (err) {
      if (!isWaking(err) || attempt >= 10) throw err;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
};

This is a separate retry from the one optimistic concurrency needs. That one wraps a transaction and retries on 40001; this one wraps a connection and retries on a message. They should not be merged, because a serialization failure after twenty seconds of waiting is a different problem from a cluster that is not awake yet.

Admin is not the only role

Everything above uses the admin role, which is the one every cluster starts with. An application should not run as it. Create a PostgreSQL role, give it what it needs, and bind it to an IAM principal:

CREATE ROLE app WITH LOGIN;
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
AWS IAM GRANT app TO 'arn:aws:iam::123456789012:role/my-lambda-role';

Then the Lambda's execution role needs dsql:DbConnect on the cluster instead of dsql:DbConnectAdmin, and the signer call is getDbConnectAuthToken() with user: 'app' in the pool. The token mechanics are otherwise identical.

Free. Unsubscribe any time with one click.