The One-Character OAuth Bug That Broke Our API

Our OAuth implementation worked perfectly. Every test passed. Users authorized apps, got tokens, refreshed them. Textbook OAuth 2.0.

Then a Pipedream integration broke.

The Problem

A user reported that their Pipedream workflow couldn't access certain API endpoints. The token was valid, the scopes were granted — but the API returned 403 Forbidden.

The error logs showed the token had zero scopes. That's impossible — we confirmed the user authorized read:urls write:urls during the consent flow.

The Root Cause

OAuth 2.0 (RFC 6749) defines scopes as space-delimited:

scope = "read:urls write:urls"

But some OAuth clients send them comma-delimited:

scope = "read:urls,write:urls"

Our scope parser split on spaces. Pipedream sent commas. The parser saw "read:urls,write:urls" as a single unknown scope, which mapped to zero valid scopes.

One character. Comma vs space.

// Before: only splits on space
String[] scopes = scopeString.split(" ");

// After: splits on comma OR space
String[] scopes = scopeString.split("[,\\s]+");

That's the fix. One regex character class. The rest of this post is about making sure it never happens again.

The Test Suite

We wrote a full end-to-end OAuth integration test: 1,605 lines covering the complete flow.

The test covers:

  1. Authorization code request — with various scope formats
  2. Token exchange — authorization code → access token
  3. Token refresh — refresh token → new access token
  4. Scope validation — comma-delimited, space-delimited, mixed, duplicates
  5. Error cases — invalid codes, expired tokens, revoked grants
  6. Real API calls — using the token against actual protected endpoints

The scope parsing tests specifically:

// Space-delimited (RFC 6749 standard)
assertScopeParsed("read:urls write:urls", Set.of("read:urls", "write:urls"));

// Comma-delimited (common in practice)
assertScopeParsed("read:urls,write:urls", Set.of("read:urls", "write:urls"));

// Mixed (yes, this happens)
assertScopeParsed("read:urls, write:urls", Set.of("read:urls", "write:urls"));

// Duplicates
assertScopeParsed("read:urls read:urls", Set.of("read:urls"));

Lessons Learned

  • RFCs are prescriptive, clients are creative — the spec says space-delimited, but real clients do whatever they want. Parse generously.
  • E2E tests catch what unit tests miss — our unit tests for scope parsing passed because they all used space-delimited scopes. The integration path through the actual OAuth flow with a real client exposed the mismatch.
  • One-character bugs hide in plain sight — the scope string looked correct in logs. You had to know that read:urls,write:urls was one scope, not two, to spot the problem.
  • Test the integration, not the unit — for auth flows especially, the value is in testing the full chain: consent → code → token → API call. Mocking any part of that chain hides bugs like this one.

Ever been bitten by a delimiter bug? What's the smallest change that broke your production? Drop it in the comments.

Building jo4.io — a URL shortener with a developer API that actually works.