Skip to content

chore(backend): Cache invalid opaque OAuth tokens locally#8519

Closed
wobsoriano wants to merge 17 commits into
mainfrom
rob/aisec-24-unauthenticated-attacker-prevents-customer-backend-from
Closed

chore(backend): Cache invalid opaque OAuth tokens locally#8519
wobsoriano wants to merge 17 commits into
mainfrom
rob/aisec-24-unauthenticated-attacker-prevents-customer-backend-from

Conversation

@wobsoriano
Copy link
Copy Markdown
Member

@wobsoriano wobsoriano commented May 11, 2026

Description

An attacker can send requests with random oat_... bearer tokens to any endpoint protected by clerkMiddleware. Each request triggers a call to the BAPI OAuth token verification endpoint, which counts against the customer's global Cloudflare WAF rate-limit cap. Enough parallel requests can exhaust this cap, causing legitimate BAPI calls to be throttled.

Why only oat_ tokens and not ak_ or mt_?

API key and M2M token verification are served entirely at the Cloudflare Edge and never reach the BAPI origin. They are fully exempt from BAPI rate-limit caps. OAuth token verification does reach the BAPI origin, so each call consumes quota from the customer's shared cap.

The fix: in-process negative cache

When BAPI returns a definitive "token not found" response for an oat_ token, we cache that token string as invalid for a short window. On the next request with the same token, we reject it locally without calling BAPI. The cache is bounded to prevent unbounded memory growth, with oldest-first eviction.

Only definitive "token not found" errors are cached. Transient errors (invalid secret key, unexpected errors) are not cached, so a customer who fixes a misconfiguration recovers immediately.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

⚠️ No Changeset found

Latest commit: 5a74cb4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment May 12, 2026 10:39pm

Request Review

@wobsoriano wobsoriano marked this pull request as ready for review May 11, 2026 18:57
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request adds in-memory negative caching for OAuth tokens to avoid redundant verification of previously invalidated tokens. The implementation includes a new oauthNegativeCache module with a 30-second TTL and 10,000-entry capacity limit, a token-type detection helper, integration points in the machine authentication request handlers that check the cache before verification and cache failed tokens, and comprehensive test coverage including unit tests for cache behavior, integration tests for request authentication, and end-to-end Playwright tests validating the full OAuth flow with cache interaction.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: implementing local caching of invalid OAuth tokens to prevent BAPI rate-limit exhaustion, which aligns with the primary objective across all modified files.
Description check ✅ Passed The description clearly explains the problem (attacker exhausting BAPI rate limits via invalid OAuth tokens), the solution (in-process negative cache), and implementation details (TTL, bounded capacity, selective caching), all of which are reflected in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/tokens/machineTokenRateLimiter.ts`:
- Around line 9-11: The current logic in machineTokenRateLimiter.ts clears the
entire buckets map when buckets.size >= MAX_BUCKETS (the buckets.clear() in the
if block), which allows an attacker to neutralize rate limits by causing key
churn; instead implement bounded eviction: when buckets.size >= MAX_BUCKETS,
evict only the least-recently-used or expired bucket(s) rather than clearing
all. Replace the global clear with an LRU/TTL eviction strategy (e.g., use an
ordered Map and delete the oldest entry(s) keyed by access time, or integrate an
LRU cache library) and update the code paths that read/write buckets so accesses
update recency metadata; keep the MAX_BUCKETS check but perform targeted
deletion of oldest/expired keys instead of buckets.clear().

In `@packages/backend/src/tokens/request.ts`:
- Around line 31-45: extractCallerIp currently trusts cf-connecting-ip /
x-real-ip / x-forwarded-for directly; change it to only use those headers when
the request is proven to come through a trusted proxy (e.g. implement and call
an isFromTrustedProxy(request) or check a trustedProxies config/allowlist) and
otherwise fall back to a non-spoofable source (runtime-provided peer IP / socket
remote address or request.conn/peer info). Update extractCallerIp and any other
places referenced (the same logic around lines 821-828 and 857-864) to gate
header parsing behind that trusted-proxy check and document/centralize the
trusted-proxy config so rate-limiter identity cannot be derived from
unauthenticated headers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 7a19f6b4-647d-4928-84ce-f9434adddd62

📥 Commits

Reviewing files that changed from the base of the PR and between 0f8aed2 and 8f7dabf.

📒 Files selected for processing (8)
  • integration/testUtils/machineAuthHelpers.ts
  • integration/tests/next-machine.test.ts
  • packages/backend/src/constants.ts
  • packages/backend/src/tokens/__tests__/machineTokenRateLimiter.test.ts
  • packages/backend/src/tokens/__tests__/request.test.ts
  • packages/backend/src/tokens/authStatus.ts
  • packages/backend/src/tokens/machineTokenRateLimiter.ts
  • packages/backend/src/tokens/request.ts

Comment thread packages/backend/src/tokens/oauthTokenRateLimiter.ts Outdated
Comment thread packages/backend/src/tokens/request.ts Outdated
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8519

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8519

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8519

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8519

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@8519

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8519

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8519

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8519

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8519

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8519

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8519

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8519

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8519

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8519

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8519

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8519

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8519

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8519

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8519

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8519

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8519

commit: 5a74cb4

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/tokens/machineTokenRateLimiter.ts`:
- Around line 9-19: The eviction runs before checking whether the incoming ip
already has a bucket, causing eviction even for requests from existing IPs;
change the logic in the token bucket handling so you first fetch existing =
buckets.get(ip) and only perform the MAX_BUCKETS eviction when existing is
undefined (i.e., about to insert a new bucket). Specifically: get existing
(buckets.get(ip)) before the eviction check, and if existing is falsy and
buckets.size >= MAX_BUCKETS then evict the oldest key; then create bucket =
existing ?? { tokens: MAX_BURST, lastRefill: now } as before. Reference:
buckets, MAX_BUCKETS, ip, existing, Bucket, tokens, lastRefill.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 8d1b6297-4946-45bc-ad4c-c68f1b83111d

📥 Commits

Reviewing files that changed from the base of the PR and between 8f7dabf and 2a1b3a8.

📒 Files selected for processing (3)
  • packages/backend/src/tokens/__tests__/machineTokenRateLimiter.test.ts
  • packages/backend/src/tokens/machineTokenRateLimiter.ts
  • packages/backend/src/tokens/request.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/backend/src/tokens/tests/machineTokenRateLimiter.test.ts
  • packages/backend/src/tokens/request.ts

Comment on lines +9 to +19
if (buckets.size >= MAX_BUCKETS) {
// Evict the oldest entry rather than clearing all buckets to prevent an attacker
// from neutralizing rate limits by forcing key churn across many distinct IPs.
const oldest = buckets.keys().next().value;
if (oldest !== undefined) {
buckets.delete(oldest);
}
}
const now = Date.now();
const existing = buckets.get(ip);
const bucket: Bucket = existing ?? { tokens: MAX_BURST, lastRefill: now };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Eviction is triggered for existing IPs at capacity, which weakens throttling.

At Line 9, eviction runs before checking whether ip already has a bucket. When buckets.size === MAX_BUCKETS, even requests from existing IPs evict other clients’ buckets, causing unintended rate-limit resets and reducing protection under load.

Suggested fix
 export function checkMachineTokenRateLimit(ip: string): boolean {
-  if (buckets.size >= MAX_BUCKETS) {
+  const existing = buckets.get(ip);
+  if (existing === undefined && buckets.size >= MAX_BUCKETS) {
     // Evict the oldest entry rather than clearing all buckets to prevent an attacker
     // from neutralizing rate limits by forcing key churn across many distinct IPs.
     const oldest = buckets.keys().next().value;
     if (oldest !== undefined) {
       buckets.delete(oldest);
     }
   }
   const now = Date.now();
-  const existing = buckets.get(ip);
   const bucket: Bucket = existing ?? { tokens: MAX_BURST, lastRefill: now };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/tokens/machineTokenRateLimiter.ts` around lines 9 - 19,
The eviction runs before checking whether the incoming ip already has a bucket,
causing eviction even for requests from existing IPs; change the logic in the
token bucket handling so you first fetch existing = buckets.get(ip) and only
perform the MAX_BUCKETS eviction when existing is undefined (i.e., about to
insert a new bucket). Specifically: get existing (buckets.get(ip)) before the
eviction check, and if existing is falsy and buckets.size >= MAX_BUCKETS then
evict the oldest key; then create bucket = existing ?? { tokens: MAX_BURST,
lastRefill: now } as before. Reference: buckets, MAX_BUCKETS, ip, existing,
Bucket, tokens, lastRefill.

@wobsoriano wobsoriano changed the title feat(backend): Add per-IP rate limiter for opaque machine token verification feat(backend): Add per-IP rate limiter for opaque OAuth token verification May 11, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
packages/backend/src/tokens/oauthTokenRateLimiter.ts (1)

9-19: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Eviction runs even for existing IPs at capacity

When buckets.size hits MAX_BUCKETS, Line 9 evicts an entry before checking whether the current ip already has a bucket. That lets traffic from existing IPs evict other tenants’ buckets and weakens throttling behavior under load.

Suggested minimal fix
 export function checkOAuthTokenRateLimit(ip: string): boolean {
-  if (buckets.size >= MAX_BUCKETS) {
+  const existing = buckets.get(ip);
+  if (existing === undefined && buckets.size >= MAX_BUCKETS) {
     // Evict the oldest entry rather than clearing all buckets to prevent an attacker
     // from neutralizing rate limits by forcing key churn across many distinct IPs.
     const oldest = buckets.keys().next().value;
     if (oldest !== undefined) {
       buckets.delete(oldest);
     }
   }
   const now = Date.now();
-  const existing = buckets.get(ip);
   const bucket: Bucket = existing ?? { tokens: MAX_BURST, lastRefill: now };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/tokens/oauthTokenRateLimiter.ts` around lines 9 - 19,
The eviction currently runs unconditionally when buckets.size >= MAX_BUCKETS
which allows an existing ip to evict others; change the logic in
oauthTokenRateLimiter so you first check for an existing bucket via
buckets.get(ip) (reference: buckets, existing, ip, Bucket), and only if existing
is undefined AND buckets.size >= MAX_BUCKETS perform the eviction (use the
existing oldest = buckets.keys().next().value and buckets.delete(oldest)). Then
proceed to create or reuse the bucket as before.
packages/backend/src/tokens/request.ts (1)

31-48: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Rate-limit identity is taken from spoofable client headers

The limiter key is built from cf-connecting-ip / x-real-ip / x-forwarded-for without a trusted-proxy gate. If requests can reach this code directly (or through untrusted hops), attackers can rotate or forge header values to bypass per-IP limits or target another bucket.

Please gate header-based extraction behind an explicit trusted-proxy policy and otherwise use a non-spoofable peer IP source provided by the runtime/network edge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/tokens/request.ts` around lines 31 - 48, The current
extractCallerIp function trusts client-controlled headers (cf-connecting-ip /
x-real-ip / x-forwarded-for) unconditionally; change it to only trust those
headers when the request comes through a configured trusted proxy (e.g., add a
trusted-proxy check like isRequestFromTrustedProxy(request) or an
options.allowProxyHeaders flag), and otherwise derive the IP from a
non-spoofable peer source (e.g., request.socket?.remoteAddress or the
runtime-provided peer IP). Update extractCallerIp (or its callers) to accept the
trusted-proxy context/config and gate the header reading behind that check so
header values are never used unless the proxy is known/trusted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/backend/src/tokens/oauthTokenRateLimiter.ts`:
- Around line 9-19: The eviction currently runs unconditionally when
buckets.size >= MAX_BUCKETS which allows an existing ip to evict others; change
the logic in oauthTokenRateLimiter so you first check for an existing bucket via
buckets.get(ip) (reference: buckets, existing, ip, Bucket), and only if existing
is undefined AND buckets.size >= MAX_BUCKETS perform the eviction (use the
existing oldest = buckets.keys().next().value and buckets.delete(oldest)). Then
proceed to create or reuse the bucket as before.

In `@packages/backend/src/tokens/request.ts`:
- Around line 31-48: The current extractCallerIp function trusts
client-controlled headers (cf-connecting-ip / x-real-ip / x-forwarded-for)
unconditionally; change it to only trust those headers when the request comes
through a configured trusted proxy (e.g., add a trusted-proxy check like
isRequestFromTrustedProxy(request) or an options.allowProxyHeaders flag), and
otherwise derive the IP from a non-spoofable peer source (e.g.,
request.socket?.remoteAddress or the runtime-provided peer IP). Update
extractCallerIp (or its callers) to accept the trusted-proxy context/config and
gate the header reading behind that check so header values are never used unless
the proxy is known/trusted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: c23325b7-a06b-4701-8474-9c52f3f23b11

📥 Commits

Reviewing files that changed from the base of the PR and between 2a1b3a8 and eca73d4.

📒 Files selected for processing (6)
  • packages/backend/src/tokens/__tests__/oauthTokenRateLimiter.test.ts
  • packages/backend/src/tokens/__tests__/request.test.ts
  • packages/backend/src/tokens/authStatus.ts
  • packages/backend/src/tokens/machine.ts
  • packages/backend/src/tokens/oauthTokenRateLimiter.ts
  • packages/backend/src/tokens/request.ts

@wobsoriano wobsoriano marked this pull request as draft May 11, 2026 22:44
@wobsoriano wobsoriano changed the title feat(backend): Add per-IP rate limiter for opaque OAuth token verification feat(backend): Cache invalid opaque OAuth tokens locally to prevent BAPI quota exhaustion May 11, 2026
@wobsoriano wobsoriano changed the title feat(backend): Cache invalid opaque OAuth tokens locally to prevent BAPI quota exhaustion chore(backend): Cache invalid opaque OAuth tokens locally to prevent BAPI quota exhaustion May 11, 2026
@wobsoriano wobsoriano marked this pull request as ready for review May 12, 2026 21:47
@wobsoriano wobsoriano changed the title chore(backend): Cache invalid opaque OAuth tokens locally to prevent BAPI quota exhaustion chore(backend): Cache invalid opaque OAuth tokens locally May 12, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/tokens/request.ts`:
- Around line 813-816: The code currently calls maybeCacheOAuthTokenAsInvalid
for all machine-token prefixes; change it so negative caching runs only for
OAuth machine tokens by checking the tokenType (from verifyMachineAuthToken) or
the tokenInHeader prefix before calling maybeCacheOAuthTokenAsInvalid.
Specifically, in the error paths around verifyMachineAuthToken and the similar
block at 845-848, wrap the maybeCacheOAuthTokenAsInvalid(token, ...) call in a
guard that confirms tokenType === 'oath' or tokenInHeader startsWith('oat_')
(use the actual tokenType value your verifier returns) and only then call
maybeCacheOAuthTokenAsInvalid, leaving handleMachineError unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: ff448b2f-6c31-4ffa-8693-84bf23f22b4e

📥 Commits

Reviewing files that changed from the base of the PR and between eca73d4 and 16a9834.

📒 Files selected for processing (5)
  • integration/testUtils/machineAuthHelpers.ts
  • packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts
  • packages/backend/src/tokens/__tests__/request.test.ts
  • packages/backend/src/tokens/oauthNegativeCache.ts
  • packages/backend/src/tokens/request.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/backend/src/tokens/tests/oauthNegativeCache.test.ts

Comment thread packages/backend/src/tokens/request.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant