This article was originally published on Jo4 Blog.
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");
}
}
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.
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"
}
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 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
for (Future<ResponseEntity<String>> future : futures) {
ResponseEntity<String> response = future.get(10, TimeUnit.SECONDS);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
}
}
This test would have caught the race condition immediately.
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)