DEV Community

Cover image for 5 Hard Lessons from Implementing Zapier OAuth in Spring Boot
Anand Rathnas
Anand Rathnas

Posted on

5 Hard Lessons from Implementing Zapier OAuth in Spring Boot

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
) {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Not this:

{
  "data": {
    "access_token": "...",
    "token_type": "bearer"
  }
}
Enter fullscreen mode Exit fullscreen mode

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() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With @Order annotations:

@Order(Ordered.HIGHEST_PRECEDENCE + 5)   // OAuthTokenEndpointFilter
@Order(Ordered.HIGHEST_PRECEDENCE + 15)  // OAuthTokenAuthenticationFilter
// JWT filter has default order
Enter fullscreen mode Exit fullscreen mode

TL;DR

  1. Strip Authorization header after OAuth auth succeeds - prevents JWT filter conflicts
  2. Don't revoke tokens on refresh - causes race conditions under concurrent requests
  3. Validate PKCE explicitly - don't trust annotations alone for security
  4. Match Zapier's exact format - tokens at top level, scope included
  5. 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)