Skip to content

Device login: rate limit the token endpoint per network and per device code - #830

Merged
schmitch merged 2 commits into
mainfrom
claude/friendly-clarke-kk1h36
Nov 3, 2026
Merged

schmitch merged 2 commits into
mainfrom
claude/friendly-clarke-kk1h36

Conversation

@schmitch

@schmitch schmitch commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #819

Problem

Anyone can poll POST /auth/device/token without an account, a device_code costs nothing to get, and the endpoint had no rate limit. A poll also used to wait for another poll's row lock while holding a pool connection, so concurrent polls of one pending code could fill the pool. Feeds, sign-ins and admin requests then failed with 503.

#819 asks for two things:

  1. A poll never waits for the row while it holds a connection.
  2. The token endpoint is rate limited per client network and per device_code.

The first is now on main, from #794 (forgepkg/forge#832): lock_for_poll uses FOR UPDATE NOWAIT, and a poll that finds the row locked answers slow_down. This PR first had its own NOWAIT change. The merge of main took #794's version, so this PR now adds the second point and the issue's test.

Change

  • Rate limits, per instance, checked before the database is asked (DevicePollLimits in auth/device.rs):
    • Per client network (IPv6 by /64): new config key auth.device_polls_per_minute, default 600, 0 turns it off. Past the limit a poll gets 429 slow_down with Retry-After: 60, the same shape as /auth/device/code's limit. 600 a minute lets 10 logins poll at the shortest interval (1 s) from one address, or 50 at the default interval (5 s). The default is high so that installs where many users share one address, or where every client appears to come from the reverse proxy, keep working.
    • Per device code: 10 polls per 10 s, which is one a second, the shortest interval the settings allow. Past it a poll gets 400 slow_down. The limit is looser than the database's own too_fast check, so a client that simply polls too fast is still slowed by that check, and its stored interval still grows by 5 s.
  • KeyedWindowLimiter<K> (forge-core): the same fixed windows as WindowLimiter, now shared through a private Windows<K>, with a key you choose. One difference: when the map is full of live windows, WindowLimiter refuses a network it does not track yet, while KeyedWindowLimiter lets an untracked key through without counting it. Otherwise anyone who filled the map with random codes would get every real login's polls refused. The network limit still applies to those polls. The code key is the first 64 bits of the code's SHA-256, so each entry has a fixed size.
  • Docs: a new Polling paragraph under Device login in docs/deployment.md, the token endpoint's row in the endpoint table, the 429 sentence, and the key in forge.toml.example.

Tests

  • crates/server/tests/device_auth.rs
    • concurrent_polls_of_a_pending_code_never_wait_for_its_row: the test the issue asks for. The server runs on a pool of 2 connections (sized_pool). It gets 50 concurrent polls of one pending code, each answered authorization_pending or slow_down within a second; at least 40 of them are cut off by the per-code limit. An unrelated request (POST /auth/device/code) is served meanwhile. The same runs again while another transaction holds the row: all 50 polls get slow_down, and the stored interval stays unchanged.
    • polls_are_limited_per_network: with device_polls_per_minute = 3, the fourth poll gets 429 with Retry-After: 60, so does a poll of an unknown code, and another network keeps polling.
  • Unit tests: DevicePollLimits (both limits), code_key, and KeyedWindowLimiter (counting per key and per window, and a full map refusing new networks but letting new keys through).
  • Run locally after merging main: all 11 device_auth integration tests (Device login: concurrent polls of an approved code hold every database connection, and the instance stops answering #794's included), the limiter and device unit tests, cargo fmt --check, cargo clippy -D warnings on the changed crates, and cargo xtask changelog --check.

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_012e6DJZVgk6pyRx2Dk7AHqN

…ken endpoint

A poll of a pending device code took a pool connection and then waited
for the request's row lock (SELECT ... FOR UPDATE), so concurrent polls
of one code, which anyone can send without an account, held up the
whole pool and every database-backed request behind it.

- lock_for_poll locks with FOR UPDATE NOWAIT. A poll that finds the row
  held (lock_not_available) answers slow_down at once, without recording
  a poll or waiting with a connection.
- POST /auth/device/token is rate limited per instance before the
  database is asked: per client network, auth.device_polls_per_minute
  (600; 429 slow_down with Retry-After), and per device code, ten polls
  per ten seconds (slow_down). A KeyedWindowLimiter shares
  WindowLimiter's windows; a map full of live windows lets an untracked
  code through, so filling it cannot refuse everyone else's polls.

The test sends 50 concurrent polls of one pending code to a server on a
two-connection pool, also while another transaction holds the row: each
is answered within a second, and an unrelated request is served.

Fixes #819

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJZVgk6pyRx2Dk7AHqN
@schmitch
schmitch added this pull request to the merge queue Nov 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 3, 2026
main now locks a poll's request FOR UPDATE NOWAIT and answers slow_down
when another poll holds it (#794, pull request #832), which this branch
also did. The merge takes main's lock_for_poll, its Busy answer and its
sized_pool test helper; this branch keeps the rate limits of the token
endpoint per client network and per device code, which #794 does not
add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJZVgk6pyRx2Dk7AHqN
@schmitch schmitch changed the title Device login: never wait for a poll's row lock, and rate limit the token endpoint Device login: rate limit the token endpoint per network and per device code Nov 3, 2026
@schmitch
schmitch added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 5ecedf7 Nov 3, 2026
19 checks passed
@schmitch
schmitch deleted the claude/friendly-clarke-kk1h36 branch November 3, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Device login: polls of a pending code wait for its row lock while holding a pool connection, and anyone can send them without an account

2 participants