Skip to content

Stop building a Supabase client on every authenticated API request - #552

Merged
ralyodio merged 1 commit into
masterfrom
worktree-fix-api-key-client-leak
Sep 8, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-fix-api-key-client-leak

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The outage

ugig.net returned 502 from 2026-09-07 13:12 UTC until a manual redeploy today, about 27 hours. The container died with FATAL ERROR: Ineffective mark-compacts near heap limit at 1046MB after 2h49m of uptime, restarted, and died the same way until Railway stopped retrying. The site is already back up — this PR is so it stays up.

This is the third time: 2026-09-01 (#540 raised the cap 384 → 1024), 2026-09-03 (#544 closed an SSE channel leak), and now. #540 said an OOM at 1GB would be evidence of a real leak. This is that evidence, and it is a different leak.

The leak

authenticateApiKey() runs on every request carrying an API key, and it built a fresh Supabase client per call:

const supabaseAdmin = createSupabaseAdmin<Database>(url, serviceKey);

Two things outlive the request that made them:

  • every createClient() allocates a RealtimeClient holding WebSocket state
  • passing no auth options leaves autoRefreshToken at its default of true, which starts a token-refresh interval that nothing ever clears

Neither is reachable once the response is sent, and neither is released. That is the "per-request resource that outlived its request" shape, on the hottest path in the app.

Every other Supabase client already guards against exactly this:

call site guard
lib/supabase/server.ts disconnectRealtime: true
lib/supabase/middleware.ts disconnectRealtime: true
lib/supabase/service.ts memoised singleton + realtime.disconnect()
authenticateWithToken() realtime.disconnect()
lib/auth/agentpass.ts uses createServiceClient()
lib/auth/api-key.ts none

The change

authenticateApiKey() now uses the memoised createServiceClient().

Five other routes built their own admin client per request the same way and are switched over too: conversations/[id]/messages, messages/send, stripe/webhook, and directory/fetch-meta.

callback/oauth and auth/agentpass-login keep their local client — they read oauth_identities and other tables missing from the generated Database types, so the typed client does not compile against them. They call realtime.disconnect() instead, which closes the same hole. Regenerating the database types is the follow-up that would let those two use the singleton as well.

Why it surfaced now

The OVH range 51.254.0.0/15 accounted for 398 of the ~467 API requests in the last log window, all to authenticated endpoints (/api/conversations/*/messages, /api/applications/my, /api/profile). It is a signed-in agent client, not a training crawler, so the crawl gateway exempts it by design — and every one of its requests minted a client.

Nothing is blocked here. The gateway's denyCidrs is checked before exempt, so adding that range would 403 a signed-in agent account, which is the customer ugig is built for. The fix is that authenticating no longer allocates.

Verification

  • Two regression tests, both run against the old code first, where they fail with 3 clients built for 3 authentications and 0 of 6 ever disconnected
  • Full suite 2095 passing (2093 before + 2 new)
  • tsc --noEmit clean
  • eslint unchanged at 0 errors, 40 pre-existing warnings
  • pnpm build green, still listing ƒ Proxy (Middleware)

Separate issue found, not fixed here

src/proxy.ts caches throttled response bodies under a key of `${ip}:${path}` with no user identity in it, and three of the four throttled paths return per-user data (/api/notifications, /api/wallet/balance, /api/wallet/transactions). Two users behind one NAT or CGNAT can be served each other's wallet balance and notifications for up to 30s. Live since #546 put the proxy into src/ on 2026-09-05. Worth its own PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_013xPsu92cNeV4t5SEGFepL6

ugig.net has been 502 since 2026-09-07 13:12 UTC. The container died with
`FATAL ERROR: Ineffective mark-compacts near heap limit` at 1046MB after
2h49m of uptime, restarted, and died the same way until Railway stopped
retrying. Redeploying restores it and buys about three hours, which is what
happened on 2026-09-01 and again on 2026-09-03.

authenticateApiKey() is the leak. It runs on every request that carries an
API key, and it built a fresh Supabase client per call:

    const supabaseAdmin = createSupabaseAdmin<Database>(url, serviceKey);

Two things outlive the request that made them. Each createClient() allocates
a RealtimeClient holding WebSocket state, and passing no auth options leaves
`autoRefreshToken` at its default of true, which starts a token-refresh
interval that nothing ever clears. Neither is reachable once the response is
sent, and neither is released.

This is the shape #544 went looking for and the reason every other Supabase
client in the app is already careful: lib/supabase/server.ts and
lib/supabase/middleware.ts pass `disconnectRealtime: true`,
authenticateWithToken() calls realtime.disconnect(), and
lib/supabase/service.ts memoises a single service client and disconnects it.
This one call site did neither, on the hottest path in the app.

It now uses createServiceClient(). Five other routes built their own admin
client per request the same way and are switched over too: the two message
routes, the Stripe webhook, and directory/fetch-meta. api/callback/oauth and
api/auth/agentpass-login keep their local client, because they read
oauth_identities and other tables missing from the generated Database types
and the typed client does not compile against them; they call
realtime.disconnect() instead, which closes the same hole.

Why it surfaced now: the OVH range 51.254.0.0/15 accounted for 398 of the
~467 API requests in the last log window, all to authenticated endpoints. It
is a signed-in agent client, not a training crawler, so the crawl gateway
exempts it by design and every one of its requests minted a client. Nothing
is being blocked here; the fix is that authenticating no longer allocates.

Two regression tests, both verified against the old code first, where they
fail with 3 clients built for 3 authentications and 0 of 6 disconnected.

Full suite 2095 passing, tsc clean, lint unchanged at 0 errors, build green
and still listing the proxy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xPsu92cNeV4t5SEGFepL6
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

45 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 36

Severity Rule Location
HIGH js-ssrf-outbound-request scripts/scan-all-skills.ts:38
MEDIUM js-open-redirect src/app/agent-login/AgentLoginForm.tsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:79
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:90
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:113
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:144
MEDIUM js-open-redirect src/components/funding/FundingClient.tsx:137
MEDIUM js-dynamic-code-execution src/lib/skills/metadata-extract.ts:300
MEDIUM js-dynamic-code-execution src/lib/skills/security-scan.ts:48
LOW secret-generic-credential cli/src/commands/auth.test.ts:66
LOW secret-generic-credential cli/src/commands/auth.test.ts:85
LOW secret-generic-api-key docs/agents/integration-guide.md:893
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:53
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:68
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:87
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:158
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:182
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:193
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:232
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:212
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:223
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:239
LOW secret-generic-credential src/lib/api.test.ts:126
LOW secret-generic-credential src/lib/api.test.ts:131
LOW js-dynamic-code-execution src/lib/skills/composite-scanner.test.ts:106
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:36
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:44
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:66
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:81
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:94
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:103
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:118
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:144
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:162
LOW js-dynamic-code-execution src/lib/skills/security-scan.test.ts:28
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:178
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:191
LOW secret-generic-credential src/lib/validations.test.ts:148
LOW secret-generic-credential src/lib/validations.test.ts:512
LOW secret-generic-credential src/lib/validations.test.ts:523
LOW secret-generic-credential src/lib/validations.test.ts:538
LOW secret-generic-credential src/lib/validations.test.ts:548
LOW secret-generic-credential src/lib/validations.test.ts:557
LOW secret-generic-credential src/lib/validations.test.ts:567
LOW secret-generic-credential src/lib/validations.test.ts:582

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 3ee3267 into master Sep 8, 2026
6 checks passed
@ralyodio
ralyodio deleted the worktree-fix-api-key-client-leak branch September 8, 2026 16:30
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.

1 participant