DEV Community

FlareCanary
FlareCanary

Posted on

Supabase's Management API OAuth Endpoint Switches From 201 to 200 on May 26 — Here's What Silently Breaks

On May 26, 2026, the OAuth token exchange endpoint for Supabase's Management API — https://api.supabase.com/v1/oauth/token — will stop returning 201 Created on success and start returning 200 OK. Same body, same fields, same access tokens. Just a different number on the status line.

Supabase's announcement is short and accurate: most clients won't notice, because they check for a 2XX success range. The libraries it calls out by name (axios, the Fetch API, MCP TypeScript SDK, Vercel AI SDK) all do that. So if you're using supabase-management-js or any other 2XX-range-aware HTTP client, you're done — go read something else.

The teams that will notice are the ones that wrote their own token-exchange handler and put if (response.status === 201) somewhere on the success path. Or assert resp.status_code == 201 in a test. Or a log filter that only counts 201 as a successful token exchange. Those clients will silently misroute a successful response to the error branch starting May 26.

This article is about why that misrouting is quieter than it looks, and where the second-order damage lands.

What Actually Changes

The endpoint is POST https://api.supabase.com/v1/oauth/token, used by third-party Supabase integrations to exchange an authorization code for an access token, and to refresh those tokens later. It's a standard OAuth 2.1 token endpoint — form-encoded request, JSON response with access_token, refresh_token, token_type, expires_in, scope.

Today:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "access_token": "sbp_v0_...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "sbp_refresh_v0_...",
  "scope": "rest projects.read",
  "token_type": "bearer"
}
Enter fullscreen mode Exit fullscreen mode

After May 26:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "access_token": "sbp_v0_...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "sbp_refresh_v0_...",
  "scope": "rest projects.read"
}
Enter fullscreen mode Exit fullscreen mode

The body doesn't move. No headers change. The rationale Supabase gives is direct: OAuth 2.1 Section 3.2.3 mandates 200 from token endpoints, and "Returning 201 is non-compliant and has caused token exchange failures with some strict OAuth clients."

In other words, the fix has been making something silently fail for strict-spec clients. The migration moves the silent failure to the lax-spec clients on May 26. There is no version of this where nobody breaks; Supabase is choosing the side of the spec.

The Four Quiet Surfaces

Surface 1: Strict-equality status checks misroute success into the error branch.

The classic shape is:

const resp = await fetch('https://api.supabase.com/v1/oauth/token', {...});
if (resp.status === 201) {
  const tokens = await resp.json();
  await db.saveTokens(userId, tokens);
  return { ok: true };
}
// Otherwise: log and return an error
console.error('OAuth token exchange failed', resp.status);
return { ok: false };
Enter fullscreen mode Exit fullscreen mode

On May 26, this code starts logging "OAuth token exchange failed 200" and returning { ok: false }after Supabase has already minted a valid access token and rotated the authorization code. The auth code is single-use. The success branch never ran. The tokens never got saved. The user sees "we couldn't connect your Supabase account," tries again, and on the retry, the original auth code has already been consumed — they get a fresh OAuth flow but the integration looks flaky.

The failure mode is the worst of both worlds: it costs you a successful authorization (the code is burned) and it presents to the user as a generic connection failure.

Surface 2: Test assertions silently flip green-to-red — but only on CI, not locally.

def test_oauth_token_exchange():
    resp = oauth_client.exchange_code(test_auth_code)
    assert resp.status_code == 201  # <-- the bomb
    assert "access_token" in resp.json()
Enter fullscreen mode Exit fullscreen mode

Pre-May 26, this test passes against a recorded fixture, against staging, against your local mock server. It also passes against production. After May 26, it fails against production only. The integration tests in CI start failing on the 201 assertion line — but only the ones that hit real Supabase. Mocked tests, snapshot tests, and stubbed tests all keep passing because the fixtures still encode the old behavior.

This is the silent-in-CI shape that hits hardest: the test suite is louder than the production code (CI starts failing) while the production code is quieter than it should be (running customers hit token errors and you have to triangulate why). Teams that run only mocked tests on PRs (and only run real integrations nightly) might not notice until the nightly fires.

The fix is the same as the production fix — change == 201 to < 300 or in range(200, 300) — but it needs to happen in the test fixtures and the snapshot files too.

Surface 3: Authorization-code reuse on retry burns the flow.

This is the second-order one and it's subtle. OAuth 2.1 authorization codes are single-use; the spec is strict. If a strict-equality check misroutes the 200 success into a retry path that re-POSTs the same code to /v1/oauth/token, the second request will fail (the code was already consumed). The integration logs the second failure, surfaces a generic error to the user, and may emit a stack trace pointing at the retry call site — making the root cause look like "Supabase rejected our authorization code" instead of "our success handler is checking for the wrong status code."

If your client has automatic retry-on-non-2XX-but-treat-201-as-success logic anywhere (Polly, Tenacity, axios-retry with a custom predicate, a hand-rolled retryer that treats 200 as an unexpected status), this is the shape you'll see: the first exchange succeeded; the retry exhausted the code; the user sees "OAuth flow failed."

The Supabase API will respond to the doomed retry with a 400 and { "error": "invalid_grant", "error_description": "Authorization code has been used" } — searching that string from a confused engineer's perspective is the path back. (If you only find this article after May 26, that error string is the canonical post-mortem hook.)

Surface 4: Observability and metrics start undercounting successful exchanges.

Three patterns to grep for in your monitoring code:

  • Log filters keyed on 201. where status_code = 201 in your Splunk/Datadog query for "successful Supabase auth" silently drops to zero on May 26. The dashboard reads as "outage," but nothing broke — the queries did.
  • Webhook/audit logging that records only specific status codes. Some custom audit pipelines emit different events for 201 Created vs other 2XX. After May 26, the created event stream stops; the audit log shows nothing where there should be daily entries.
  • Alerting rules that fire on "no 201 in the last hour." Pages on-call when the underlying system is healthy.

The cure here is the same shape as the test fix: replace status-equality with status-range, regenerate dashboards, update audit-event mappings. The work is small if you grep for it; the work is bottomless if you wait for the dashboards to tell you.

Who Should Audit Right Now

Three groups, ranked by likely impact:

  1. Anyone building a Supabase integration from scratch. If you wrote your own OAuth client (because you needed something supabase-management-js didn't expose, or you're in a language without a maintained Supabase library, or you wanted to control the token-cache layer yourself), search your codebase for the literal 201 near anything OAuth-related.

  2. Anyone shipping a Supabase integration in a typed language with overly-specific status types. The pattern looks like enum Response { Created(Tokens), ... } or case .created(let body): ... — Swift, Rust, Kotlin, Scala. Strict status-typing is what bites this surface hardest; the compiler can't help you, but a grep for the literal 201 or the language-specific created enum variant will.

  3. Anyone whose CI pipeline does real-network integration tests against api.supabase.com. Even if the production code is fine, the test fixtures might pin 201 and turn the build red on May 26 for no production-impacting reason.

The Migration Is Trivial; The Audit Is the Work

The actual code change is a one-liner per call site:

- if (resp.status === 201) {
+ if (resp.ok) {  // covers 200, 201, and anything else in 2XX
Enter fullscreen mode Exit fullscreen mode

Or, in Python:

- if resp.status_code == 201:
+ if 200 <= resp.status_code < 300:
Enter fullscreen mode Exit fullscreen mode

Or the more spec-precise version that matches OAuth 2.1's expectations:

- if resp.status_code == 201:
+ if resp.status_code == 200:
Enter fullscreen mode Exit fullscreen mode

The first form is the most forgiving and the one Supabase's announcement recommends. The third is the most spec-faithful and breaks again only if Supabase migrates to a different success code in the future (which they won't — 200 is the spec).

Once the production code is fixed, sweep:

  • Unit tests asserting on response status
  • Snapshot tests / contract tests with hardcoded 201 literals
  • Logging templates with "status=201" formatted in
  • Monitoring queries grouped or filtered by status code
  • Audit-log producers emitting different event types per 2XX status

If you've got grep, this is a 20-minute job. If you don't, it's the kind of thing that surfaces in a frantic Slack message four days after May 26.

The Pattern, Beyond Supabase

Status-code compliance migrations are a recurring shape: an API was returning the wrong-but-working status, strict spec compliance forces a change, the change is technically harmless, and the only code that breaks is the code that violated the contract twice — once by depending on a specific status code, and once by treating the wrong status code as canonical. The same kind of move shows up periodically in Stripe API version changes, in GitHub's REST API breaking changes, in payment provider response normalizations.

What's interesting from a runtime-monitoring angle is that the affected systems are exactly the ones with the least observability — they're the hand-rolled clients, the custom integrations, the test fixtures that nobody touches. The libraries with strong status-code abstractions absorb the change silently, which is the right behavior; the systems without those abstractions absorb it as silent failure.

This is the same pattern we see across other "minor" API changes that turn into customer-impacting bugs weeks later: the migration is two lines of code, but the audit is across every place anyone touched the API surface — and most teams don't have a single inventory of those places. That's the lookup problem that FlareCanary exists to solve at the response-shape layer: watch the API, catch the structural change, route the alert to whoever's name is on the integration. The body isn't changing on May 26, so a content monitor wouldn't catch this one — but a status-code or header diff would.

If you're shipping a Supabase Management API integration and 201 is anywhere in your codebase, this is the moment to find it. Two weeks of runway, a one-line fix, and a quiet failure mode if you wait.


If you're maintaining a Supabase OAuth integration and find this article while staring at an invalid_grant error you don't remember writing, the fix is at the success-handler call site, not at the retry layer. Drop a comment if the failure shape looked different from what I described — the more shapes we catalog the better.

Top comments (0)