Menu

Developer documentation

Rate limiting: memory and shared PostgreSQL counters

/docs/rate-limiting

Status: adopted 2026-08-04.

Signet rate-limits the normalized auth path per client address. It is enabled by default and keeps better-auth's response contract: a spent bucket returns HTTP 429 with x-retry-after: <seconds> and {"message":"Too many requests. Please try again later."}. Rules and client identity are identical in both storage modes; only counter coordination changes.

Choose storage deliberately

The default is process-local memory:

[rate_limit]
enabled = true
storage = "memory"

This is fast, needs no database table, and preserves the established single-process behavior. A restart clears its counters, and two processes have independent quotas.

Use PostgreSQL storage when more than one Signet process serves the same authentication origin:

[database]
adapter = "postgres"
dsn = "env:SIGNET_DATABASE_URL"
migrate = true

[rate_limit]
enabled = true
storage = "database"
window = 10
max = 100

storage = "database" is rejected with the memory adapter because it would claim coordination that does not exist. The embedded database migration creates the rateLimit table. When database.migrate = false, apply that migration through the deployment's migration workflow before accepting traffic.

Every node sharing the table must have:

  • the same default and [[rate_limit.rules]] values;
  • a synchronized system clock, because lastRequest is an epoch-millisecond application time; and
  • the same trusted client-IP boundary. Signet uses the first X-Forwarded-For value, so the last trusted proxy must replace, not append to, any client-supplied header. See the reverse-proxy guide.

Concurrency and failure behavior

The shared path opens a row by its unique key, then uses a guarded increment_one statement. PostgreSQL locks and rechecks one matching row before changing count and lastRequest. A request which loses that guard re-reads the committed state and decides again; concurrent nodes therefore cannot each spend the same last quota slot. Denied requests do not extend the window.

Expired rows are pruned best-effort, at most once per longest configured window per process. A prune failure is logged and retried later without disabling enforcement.

Counter reads and writes fail closed. If the selected database storage is unavailable or a row has an invalid shape, the request receives the generic HTTP 500 INTERNAL_SERVER_ERROR envelope and the typed cause stays in operator logs. Signet does not silently admit uncounted authentication work. This makes PostgreSQL availability part of authentication availability when database storage is selected; monitor both.

What this does not do

Shared storage stops a client from multiplying its quota by the number of Signet processes. It is not bot detection and it does not absorb traffic before origin/database work. Public deployments should retain the trusted-edge controls in the bot-protection guide, and password endpoints should retain persistent identity lockout for attempts distributed across addresses.

Enter to open · Esc to close