Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rate limits, IP rules and CAPTCHA

rIDM’s sign-in and OAuth endpoints are exposed to the internet by design, so they carry several layers of abuse protection. This page covers each control, what it counts or matches, its defaults and where to change it, and the deployment settings that decide which address a request comes from.

ControlScopeConfigured in
Request ceilingsper address, per client, per tenant, per deploymentsettings.rate_limits, RATE_LIMITS, RATE_LIMIT_IP_PER_MINUTE
IP rulestenant-wide and per client/admin/tenants/{slug}/ip-rules
Account lockoutper user, per addresssettings.lockout
CAPTCHAper sign-in flowsettings.captcha and /admin/tenants/{slug}/captcha
Breached passwordsevery password setsettings.password.check_breached, BREACH_CHECK_URL
Cross-origin policyevery responseclient cors_origins
Security headersevery responseHSTS_MAX_AGE
Outbound request policyevery URL a tenant or client choosesfixed

All of them depend on knowing the client’s address correctly, so start with the client address.

The client address

rIDM uses the TCP peer’s address unless the peer is a trusted proxy. TRUSTED_PROXIES lists the proxies and load balancers in front of rIDM, as CIDRs or single addresses, comma-separated:

TRUSTED_PROXIES=10.0.0.0/8,192.0.2.10

When the peer is in that list, rIDM reads X-Forwarded-For (and, when that is absent, the RFC 7239 Forwarded header) from the right: it skips entries that are themselves trusted proxies and takes the first one that is not. Proxies such as AWS load balancers and nginx with $proxy_add_x_forwarded_for append to whatever the caller sent, so the leftmost entry is attacker-controlled; the rightmost untrusted entry is the address your outermost proxy actually saw. An unparsable entry stops the walk.

That one address is what request ceilings count, what IP rules match, what the lockout throttle and CAPTCHA verification see, and what the audit log records. Get it wrong and every control misbehaves:

  • TRUSTED_PROXIES empty behind a proxy: every client appears as the proxy, so one busy office exhausts the per-address limits for everyone and IP rules match the proxy.
  • A proxy listed that does not overwrite or append the header: callers choose their own address.

TRUSTED_PROXIES also decides whether X-Forwarded-Host is believed, which matters for custom domains. See TLS and reverse proxies.

Request ceilings

What is limited

Three endpoint families have ceilings; each has its own per-address limit.

FamilyEndpoints (under /t/{slug})Refusal format
token/token, /introspect, /revoke, /userinfo, /device_authorizationOAuth JSON: 429, {"error": "slow_down"}
authorize/authorize (HTML page), /par and dynamic registration (OAuth JSON)as noted
flowsthe flow API, recovery, email verification, invitations, device verification (application/problem+json); brokering (HTML page)as noted

Discovery, JWKS, WebFinger, branding, /end_session, the account API, the admin API and SCIM are outside these families and not limited by them.

Each request is counted in up to four fixed-window buckets in Valkey, shared by every node:

BucketKeyLimit
deployment, per addressridm:rl:ip:{addr}RATE_LIMIT_IP_PER_MINUTE per 60 s, across every tenant and family
tenant, per address and familyridm:rl:t:{tenant}:{family}:ip:{addr}token_per_ip, authorize_per_ip or flows_per_ip per window
tenant, per clientridm:rl:t:{tenant}:client:{client}token_per_client per window, token family only
tenant totalridm:rl:t:{tenant}:alltenant_total per window, every limited request of the tenant

The per-client bucket is charged once the client is identified and before its secret or assertion is checked, so guessing a client secret is bounded by it too.

Defaults

Per tenant, settings.rate_limits (console: Settings → Rate limits):

SettingDefaultMeaning
enabledtrueswitch the tenant’s own buckets off (the deployment bucket still applies)
window_secs60window length, 1–3600 seconds
token_per_ip600token family, per address
token_per_client1200token family, per client
authorize_per_ip300authorize family, per address
flows_per_ip600flows family, per address
tenant_total0everything together; 0 = no ceiling

Any individual limit set to 0 is off. The defaults are generous on purpose: they are meant to hold for a busy office behind one NAT address. Tighten authorize_per_ip and flows_per_ip for consumer-facing tenants.

For the deployment:

VariableDefaultMeaning
RATE_LIMITStruemaster switch; false disables every ceiling, for tests and local experiments only
RATE_LIMIT_IP_PER_MINUTE6000per-address ceiling across all tenants; 0 = off
curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"rate_limits": {"authorize_per_ip": 60, "tenant_total": 20000}}}'

What a client sees

Every limited response carries the fields of the tightest bucket:

RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42

A refused request is 429 with Retry-After (seconds until the bucket’s window ends), in the family’s format from the table above. Browser navigations (/authorize, brokering) get a plain HTML page rather than a redirect to the client. These headers are exposed to browsers through CORS.

Caveats

  • Valkey being unreachable fails open: requests are allowed and a warning is logged. The ceilings protect against abuse; they are not an authorization control.
  • Windows are fixed, not sliding: a client can send up to twice a limit across a window boundary.
  • A request whose address cannot be determined counts only against the tenant total.

IP rules

IP rules allow or deny networks, either for the whole tenant or for one client.

How they decide

Rules form two scopes: tenant-wide (no client) and per client. Within a scope:

  1. the most specific rule whose network contains the address decides (a /32 beats a /24), whether it says allow or deny;
  2. an address that matches no rule passes, unless the scope has any allow rule, in which case the scope is an allow list and the address is refused.

A request must pass both scopes. The tenant scope is checked on every endpoint of the three families, before rate limiting and before anything else runs. The client scope is checked once the client is known: at /authorize (a refusal is an HTML page, never a redirect to the client) and at every client-authenticated endpoint (access_denied, before the secret is examined).

WhereRefusal
flow API403, application/problem+json
OAuth JSON endpoints403 with an OAuth error body
/authorize, brokering403 HTML page

If the rules cannot be read (the database and cache both unavailable), requests are refused with 503, not waved through.

Managing rules

Console: IP rules (/console/ip-rules/). Admin API, with ridm:tenants:read / ridm:tenants:write:

# Only the office and the VPN may sign in to this tenant
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "allow", "cidr": "203.0.113.0/24", "description": "Office"}'

# Except one address in it
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "deny", "cidr": "203.0.113.77"}'

# A machine client that may only be used from its build network
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"client_id": "<client uuid>", "action": "allow", "cidr": "2001:db8:42::/48"}'

action defaults to deny. cidr takes a network or a single IPv4 or IPv6 address and is stored in canonical form (203.0.113.9/24 becomes 203.0.113.0/24). One network may appear once per scope (409 otherwise). client_id is the client’s internal id. GET …/ip-rules?client_id=… or ?tenant_wide=true filters the list; PATCH changes action, cidr or description; DELETE removes a rule. Changes take effect at once on every node.

Caveats

  • An allow rule turns its scope into an allow list. Adding your office network as allow locks out everyone else, including remote administrators signing in to the console through this tenant. Keep a way in (another tenant, such as master, holds global administrators) before creating the first tenant-wide allow rule.
  • Rules apply to the endpoint families above, not to the admin API, the account API, SCIM, discovery or JWKS.
  • The address is the one described in The client address; with TRUSTED_PROXIES wrong, every request appears to come from the proxy.

Account lockout and address throttling

settings.lockout (console: Settings → Passwords & lockout) protects password sign-in against guessing:

SettingDefaultMeaning
max_failures10consecutive wrong passwords before the account is locked; 0 = off
lock_minutes15how long the lock lasts
ip_max_failures100failed password attempts from one address within the window before that address is throttled; 0 = off
ip_window_minutes15the window, and the Retry-After of a throttled address

A successful sign-in resets the account’s failure count. Reaching max_failures raises user.locked; while locked, even the right password is refused. An administrator can lift the lock early with POST /admin/tenants/{slug}/users/{user}/unlock or the Unlock button on the user’s page. The address throttle answers 429 before the password is even checked. Attempts are recorded as login attempts (and the login.failed event) with a reason: invalid_credentials, locked or disabled.

A lockout policy is a trade-off: max_failures lets anyone who knows a username lock that user out. Rely on the address throttle, the request ceilings and CAPTCHA for volume, and keep max_failures high enough not to become a denial-of-service tool.

CAPTCHA

rIDM supports Cloudflare Turnstile and hCaptcha. A tenant has at most one provider:

curl -X PUT https://id.example.com/admin/tenants/acme/captcha \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"provider": "turnstile", "site_key": "0x4AAAAAAA…", "secret": "0x4AAAAAAA…"}'

provider is turnstile or h_captcha; site_key and secret are required; verify_url optionally replaces the vendor’s siteverify endpoint (for a proxy or tests). The configuration is stored encrypted; GET …/captcha returns it with the secret replaced by secret_set: true (or 204 when none is configured) and DELETE removes it. In the console it sits under Settings → Passwords & lockout, beside the two settings below, and saves once both keys are present. The permissions are ridm:tenants:read / ridm:tenants:write.

When a challenge is demanded is settings.captcha:

SettingDefaultMeaning
after_failures3demand a challenge once a sign-in flow has this many failed attempts; 0 = never
on_registrationtruedemand a challenge on every self-registration

The flow state tells the sign-in page when a challenge is due (provider and site key), and the page renders the widget. A missing token is refused as a validation error on captcha_token (captcha_required), a rejected one as captcha_failed. If the vendor cannot be reached, the attempt fails with 503 rather than skipping the check.

Without a configured provider there is no challenge at all, whatever settings.captcha says. settings.captcha.on_registration is the only registration switch: an earlier settings.registration.captcha flag was removed (a migration carried any true value over into captcha.on_registration, and a PATCH that names it is refused with 400).

The verify_url, like every URL a tenant administrator chooses, is reached under the outbound request policy.

Breached-password check

With settings.password.check_breached on (default off; console: Settings → Passwords & lockout), every password a user or administrator sets (registration, recovery, forced change, the account console, an administrator’s reset, and imports carrying a plaintext password) is looked up in a Have I Been Pwned compatible range API. Only the first five hex digits of the password’s SHA-1 leave the server; the request asks for a padded answer and the match is made locally. A password that appears in the corpus is refused as a validation error on password.

VariableDefaultMeaning
BREACH_CHECK_URLhttps://api.pwnedpasswords.com/range/the range endpoint; off, none, false or empty disables the check for the whole deployment (air-gapped installs), and the tenant toggle is then inert

A lookup that fails (timeout after 5 seconds, an error status) is logged and lets the password through, so an outage of the corpus never blocks sign-ups or resets. To keep the check without reaching the internet, host a mirror of the range API and point BREACH_CHECK_URL at it.

Cross-origin requests

One rule set decides which browser origins may call the API, with credentials:

OriginAdmitted on
the UI’s and the API’s own origins (UI_URL, PUBLIC_URL)everything
any origindiscovery, JWKS and branding of every tenant, WebFinger, /.well-known/security.txt
the tenant’s custom domain (https://<domain>)that tenant’s paths
any origin in the cors_origins of one of the tenant’s active clientsthat tenant’s paths
anything elsenothing: no CORS headers are sent, so the browser refuses the response

The union of client origins is cached per tenant and evicted on every client change. Because a preflight cannot say which client a /token call is for, client-authenticated endpoints check again once the client is known: a browser Origin that is neither the UI’s, the API’s, the custom domain’s nor registered on that very client is refused with invalid_request, even though the tenant as a whole admits it. Server-side clients send no Origin and are never checked. A single-page app therefore needs its origin in its own client’s cors_origins; see Registering clients.

Preflights are cached by browsers for ten minutes. Allowed methods are GET, POST, PUT, PATCH, DELETE and OPTIONS; allowed request headers are Authorization, Content-Type, Accept, Accept-Language, If-None-Match, DPoP and X-Requested-With; Retry-After, WWW-Authenticate, Location, ETag, DPoP-Nonce and the three RateLimit-* headers are exposed.

Security headers

Every API response carries, unless the handler set its own:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policyno-referrer
Content-Security-Policydefault-src 'none'; frame-ancestors 'none' (under /docs, only frame-ancestors 'none', since Swagger UI needs its scripts)
Strict-Transport-Securitymax-age=<HSTS_MAX_AGE>; includeSubDomains, only when PUBLIC_URL is https

The HTML pages the API renders itself (authorization errors, refusals) use default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'. HSTS_MAX_AGE defaults to 63072000 (two years); 0 turns the header off. Because of includeSubDomains, only serve PUBLIC_URL over https on a domain whose subdomains are all https too.

The UI is a static export with its own Content-Security-Policy meta tag on every page: scripts only from the page’s origin, the CAPTCHA vendors and the page’s own inline scripts by SHA-256 hash. A meta tag cannot restrict framing, so the server adds that when it serves the embedded UI: X-Frame-Options: DENY and frame-ancestors 'none' on every page except /login/, which gets SAMEORIGIN and frame-ancestors 'self' because the admin console shows it in an iframe as the live branding preview (the console pages’ own policy is the only one that allows framing this origin). A static host serving ui/out itself must send the same headers, and Strict-Transport-Security.

Cookies set by the API are HttpOnly, SameSite=Lax, host-only with Path=/, named per tenant (__Host-ridm_session_acme, __Host-ridm_device_acme), and Secure unless COOKIE_SECURE=false, which is only for plain-http development and drops the __Host- prefix. See TLS and reverse proxies.

Outbound request policy

Several URLs rIDM connects to are chosen by tenant administrators or by whoever registers a client, not by the operator. Left unchecked, any of them could point the server at its own network (server-side request forgery). These are covered:

TargetChosen by
Webhook receiverstenant administrator
Back-channel logout URIsclient registration
Client jwks_uri (for private_key_jwt)client registration
Identity-provider discovery, token, userinfo and JWKS endpointstenant administrator
A tenant’s HTTP email gateway and SMS webhooktenant administrator
A tenant’s CAPTCHA verify_urltenant administrator
A tenant’s SMTP servertenant administrator

For all of them:

  • Names resolve to public addresses only. The check is made when the connection is made, not only when the URL is saved, so a name that later resolves to a private address (DNS rebinding) is refused too. Refused ranges: private (RFC 1918), loopback, link-local (including 169.254.169.254), unspecified, broadcast, multicast, carrier-grade NAT (100.64.0.0/10), unique-local IPv6 (fc00::/7), documentation, benchmarking and reserved ranges, and IPv6 forms that embed one of them (IPv4-mapped, IPv4-compatible, NAT64, 6to4). A name with no public address left fails.
  • IP literals in a private range are refused before any request is sent; webhook URLs and tenant SMTP hosts that are private literals are refused when saved (400).
  • Redirects are not followed, since a redirect would be a second, unchecked target.
  • HTTP_PROXY and HTTPS_PROXY are ignored for these requests, because a proxy would resolve the name out of the check’s sight.
  • Development allowance: loopback literals (127.0.0.0/8, ::1) and the host name localhost may reach loopback. Any other name resolving to loopback is refused.
  • Networks the operator opens: OUTBOUND_ALLOW_NETWORKS lists private networks these requests may reach anyway, for applications that live on an internal network (a back-channel logout endpoint at https://orders.internal, a webhook receiver in the same VPC). An address inside one counts as public, literal or resolved. Open the narrowest networks that hold those applications, not the whole private range: a tenant administrator can point a webhook at anything inside them.
OUTBOUND_ALLOW_NETWORKS=10.20.0.0/16,192.168.40.12

A tenant’s SMTP server is resolved just before each connection; rIDM connects to the first allowed address it found (only that one is tried) while TLS still verifies the certificate against the configured host name.

The URLs the operator sets in the environment are trusted and not filtered: the deployment’s SMTP_HOST (a private relay is the usual case), AUDIT_SINK_URL, BREACH_CHECK_URL and OTEL_EXPORTER_OTLP_ENDPOINT. An egress firewall remains the place to restrict where nodes may connect at all; see Deployment overview.