Three days. That's how long it took to get a "simple" OAuth integration working with Zapier. The docs made it look easy. Reality had other plans.
Here's what I learned building OAuth 2.0 with PKCE for jo4.io - a URL shortener that now integrates with Zapier, Make.com, and n8n.
Lesson 1: Your OAuth Tokens Fight Your JWT Tokens
Spring Security's filter chain doesn't know the difference between your OAuth access tokens and Auth0's JWTs. Both arrive as Authorization: Bearer <token>. Chaos ensues.
The Problem
OAuth token authentication successful: userId=2, clientId=zapier
...
BearerTokenAuthenticationFilter: Failed to authenticate: Invalid JWT
Wait, what? We just authenticated successfully. Why is it failing?
The JWT filter runs after our OAuth filter and tries to re-validate the same token as a JWT. It fails (obviously - it's not a JWT), and returns 401.
The Fix
Strip the Authorization header after successful OAuth authentication:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain) {
String token = extractBearerToken(request);
// Skip if this looks like a JWT (has 3 base64 parts with "alg" header)
if (isJwtToken(token)) {
filterChain.doFilter(request, response);
return;
}
try {
OAuthService.ValidatedToken validated = oauthService.validateAccessToken(token);
SecurityContextHolder.getContext().setAuthentication(
new OAuthTokenAuthentication(validated.getUser(), validated.getToken())
);
// CRITICAL: Strip header so JWT filter doesn't try to re-validate
filterChain.doFilter(new AuthorizationStrippingRequestWrapper(request), response);
} catch (Exception e) {
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "invalid_token",
"The access token is invalid, expired, or revoked");
}
}
private static class AuthorizationStrippingRequestWrapper extends HttpServletRequestWrapper {
@Override
public String getHeader(String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return null;
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return Collections.emptyEnumeration();
}
return super.getHeaders(name);
}
}
Key insight: Filter chain order matters. Your OAuth filter runs, authenticates, then passes to the next filter. If that next filter sees a Bearer token, it'll try to process it again.
Lesson 2: Concurrent Token Refresh = Race Condition Hell
Zapier's backend makes parallel requests. When a token expires, multiple workers hit your refresh endpoint simultaneously.
The Problem
Thread-1: Refresh token, create new access token A
Thread-1: Revoke old access token (standard practice, right?)
Thread-2: Refresh token, create new access token B
Thread-2: Revoke old access token... wait, that's token A that Thread-1 just created
User sees: "This account is expired. Please reconnect it."
The (Counter-Intuitive) Fix
Don't revoke access tokens on refresh. Let them expire naturally.
public TokenResponse refreshAccessToken(String refreshToken) {
OAuthRefreshTokenEntity refresh = validateRefreshToken(refreshToken);
// Create new access token
String newAccessToken = generateAccessToken();
saveAccessToken(newAccessToken, refresh.getUserId(), refresh.getClientId());
// NOTE: We intentionally do NOT revoke old access tokens.
// This prevents race conditions when multiple concurrent refresh
// requests would revoke each other's newly created tokens.
// Access tokens have short lifetimes (1 hour) and expire naturally.
return new TokenResponse(newAccessToken, refresh.getToken(), 3600);
}
This is standard OAuth 2.0 practice. Access tokens are short-lived by design. Revoking them on refresh is "clever" but breaks under concurrency.
Lesson 3: PKCE Validation Must Be Explicit
@NotBlank on your DTO doesn't always trigger. Bean validation has quirks.
The Problem
Our request DTO:
public record AuthorizeConsentRequest(
@NotBlank String clientId,
@NotBlank String redirectUri,
String scope,
@NotBlank(message = "PKCE code_challenge is required") String codeChallenge,
String codeChallengeMethod
) {}
But requests without codeChallenge were getting through. The security bug: attackers could bypass PKCE entirely.
The Fix
Defense in depth - validate explicitly:
@PostMapping("/authorize")
public ResponseEntity<?> authorizeConsent(@Valid @RequestBody AuthorizeConsentRequest request) {
// SECURITY: Explicit PKCE validation (defense-in-depth)
if (request.codeChallenge() == null || request.codeChallenge().isBlank()) {
return errorResponse("invalid_request",
"PKCE code_challenge is required per OAuth 2.1 security requirements");
}
// ... rest of authorization logic
}
Rule: Security validations should be explicit in code, not just annotations. Annotations can be bypassed, misconfigured, or simply not triggered.
Lesson 4: Zapier Has Specific Token Response Requirements
The OAuth spec is flexible. Zapier is not.
Requirements I Discovered the Hard Way
| Requirement | What I Did Wrong | What Zapier Needs |
|---|---|---|
| Token location | Nested in data object |
Top-level JSON keys |
| Content-Type | application/json; charset=UTF-8 |
application/json works, but verify |
scope field |
Omitted (optional per spec) | Must be present |
token_type |
Returned Bearer
|
Must be exactly Bearer (case-sensitive) |
Correct response format:
{
"access_token": "jo4_at_abc123...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "jo4_rt_xyz789...",
"scope": "read write"
}
Not this:
{
"data": {
"access_token": "...",
"token_type": "bearer"
}
}
Lesson 5: E2E Tests Are Worth the Investment
After two days of "try it and see" debugging, I wrote comprehensive E2E tests. Found three bugs in 10 minutes.
The Test Structure
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ZapierOAuthE2EIntegrationTest {
@Test @Order(1) void step1_registerOAuthClient() { /* ... */ }
@Test @Order(2) void step2_initiateAuthorization() { /* ... */ }
@Test @Order(3) void step3_userGrantsAuthorization() { /* ... */ }
@Test @Order(4) void step4_exchangeCodeForTokens() { /* ... */ }
@Test @Order(5) void step5_useAccessToken() { /* ... */ }
@Test @Order(6) void step6_refreshToken() { /* ... */ }
@Test @Order(9) void step9_concurrentRefresh() { /* ... */ }
}
The Concurrent Refresh Test That Found the Race Condition
@Test
void step9_concurrentRefreshDoesNotCauseRaceCondition() throws Exception {
int concurrentRequests = 5;
ExecutorService executor = Executors.newFixedThreadPool(concurrentRequests);
List<Future<ResponseEntity<String>>> futures = new ArrayList<>();
// Fire 5 refresh requests simultaneously
for (int i = 0; i < concurrentRequests; i++) {
futures.add(executor.submit(() -> {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "refresh_token");
params.add("refresh_token", refreshToken);
params.add("client_id", testClientId);
params.add("client_secret", testClientSecret);
return restTemplate.postForEntity("/oauth/token",
new HttpEntity<>(params, formHeaders), String.class);
}));
}
// ALL must succeed
List<String> accessTokens = new ArrayList<>();
for (Future<ResponseEntity<String>> future : futures) {
ResponseEntity<String> response = future.get(10, TimeUnit.SECONDS);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
accessTokens.add(extractAccessToken(response.getBody()));
}
// ALL tokens must work
for (String token : accessTokens) {
ResponseEntity<String> userInfo = callUserInfo(token);
assertThat(userInfo.getStatusCode().is2xxSuccessful()).isTrue();
}
}
This test would have caught the race condition immediately instead of after two days of production debugging.
The Complete Filter Chain Order
For reference, here's what finally worked:
Request
↓
OAuthTokenEndpointFilter (strips Bearer from /oauth/token, /oauth/revoke)
↓
OAuthTokenAuthenticationFilter (validates opaque OAuth tokens)
↓
BearerTokenAuthenticationFilter (validates JWTs - skipped if already authed)
↓
Your Controllers
With @Order annotations:
@Order(Ordered.HIGHEST_PRECEDENCE + 5) // OAuthTokenEndpointFilter
@Order(Ordered.HIGHEST_PRECEDENCE + 15) // OAuthTokenAuthenticationFilter
// JWT filter has default order
TL;DR
- Strip Authorization header after OAuth auth succeeds - prevents JWT filter conflicts
- Don't revoke tokens on refresh - causes race conditions under concurrent requests
- Validate PKCE explicitly - don't trust annotations alone for security
-
Match Zapier's exact format - tokens at top level,
scopeincluded - Write E2E tests first - 10 minutes of tests beats 2 days of production debugging
OAuth looks simple in diagrams. The edge cases will humble you.
What OAuth integration horror stories do you have? I'd love to hear I'm not alone.
Building jo4.io - URL shortener with Zapier, Make.com, and n8n integrations.
Top comments (0)