Skip to main content
the auth layer

Confused Deputy and Open Redirect: Attacks That Exploit Trust Relationships

5 min read Chapter 33 of 45

Confused Deputy and Open Redirect

The Assumption

If a token is valid and not expired, the request carrying it is authorized. If the redirect URI is on our domain, the redirect is safe. Two assumptions. Both wrong.

The confused deputy attack does not require an invalid token. It requires a valid token used in an unintended context. The open redirect attack does not require a malicious domain in the redirect. It requires insufficient validation of the redirect URI parameter.

The Attack

Confused Deputy (Cross-Boundary)

The tenant-specific confused deputy was covered in CH8. This variant is different: it exploits boundaries between resource types, not tenants.

Alice has a valid token with scopes project:read project:write. She sends a request to DELETE /api/billing/subscription. The billing endpoint checks: is the token valid? (Yes.) Is the user authenticated? (Yes.) Does the user have write permissions? (Yes, project:write.) The billing endpoint serves the request and cancels Alice’s subscription.

The flaw: project:write does not mean billing:write. The endpoint checked for “write” authority without checking the resource type. The token’s scopes were intended for project operations, not billing operations.

Defense: Scope-specific authorization, not generic read/write checks.

Open Redirect in OAuth2

The OAuth2 authorization code flow redirects the user back to the client after authentication. The redirect_uri parameter tells the authorization server where to send the authorization code.

Attack:

  1. Attacker constructs an authorization URL with a crafted redirect_uri: https://auth.saas.example/authorize?client_id=frontend-shell&redirect_uri=https://app.saas.example/callback/../../../evil.example/steal&response_type=code&state=xyz

  2. The authorization server validates the redirect_uri. If it uses prefix matching instead of exact matching, https://app.saas.example/callback/../../../evil.example/steal may pass validation (it starts with the registered base URI).

  3. After authentication, the authorization server redirects the user (with the authorization code) to the attacker-controlled URL.

  4. The attacker receives the authorization code and exchanges it for tokens.

Even without path traversal, open redirect vulnerabilities within the registered redirect URI can be chained: https://app.saas.example/callback?next=https://evil.example if the callback page performs client-side redirect to the next parameter.

The Spec or Mechanism

Redirect URI Validation (RFC 6749 Section 3.1.2.2)

The authorization server MUST require the client to register their complete redirect URI. The authorization server MUST compare using exact string matching.

Spring Authorization Server enforces exact string matching by default. The registered redirect_uri must match the request’s redirect_uri character for character. No prefix matching, no pattern matching, no path traversal normalization.

Scope-Based Authorization

OAuth2 scopes are the mechanism for confused deputy prevention. A token’s scope limits what operations it authorizes. The resource server must enforce scopes at the endpoint level, not just check for “authenticated.”

The Implementation

Scope-Specific Authorization

// VULNERABLE: Generic write check, confused deputy possible
@Bean
public SecurityFilterChain vulnerableChain(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/**").authenticated() // Only checks authentication
        )
        .build();
}
// HARDENED: Scope-specific authorization per resource type
@Bean
public SecurityFilterChain hardenedChain(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(tenantAwareJwtConverter()))
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.GET, "/api/projects/**")
                .hasAuthority("SCOPE_project:read")
            .requestMatchers(HttpMethod.POST, "/api/projects/**")
                .hasAuthority("SCOPE_project:write")
            .requestMatchers(HttpMethod.DELETE, "/api/projects/**")
                .hasAuthority("SCOPE_project:write")
            .requestMatchers(HttpMethod.GET, "/api/billing/**")
                .hasAuthority("SCOPE_billing:read")
            .requestMatchers(HttpMethod.POST, "/api/billing/**")
                .hasAuthority("SCOPE_billing:write")
            .requestMatchers(HttpMethod.DELETE, "/api/billing/**")
                .hasAuthority("SCOPE_billing:admin")
            .anyRequest().denyAll()
        )
        .build();
}

Open Redirect Prevention

// Spring Authorization Server: redirect URI validation is exact by default
// No additional configuration needed for the standard case.
// But if you customize redirect URI validation, ensure exact matching:

@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder) {

    RegisteredClient frontendShell = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("frontend-shell")
        .clientSecret(passwordEncoder.encode("secret"))
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        // Register EXACT redirect URIs, not patterns
        .redirectUri("https://app.saas.example/callback")
        // Do NOT register broad patterns:
        // .redirectUri("https://app.saas.example/**")  ← NEVER DO THIS
        .build();

    return new InMemoryRegisteredClientRepository(frontendShell);
}

Client-Side Redirect Validation

After the OAuth2 callback, the client may redirect the user to a “return URL.” This must be validated:

@GetMapping("/callback")
public String handleCallback(@RequestParam String code,
                              @RequestParam(required = false) String returnUrl) {

    // Exchange code for tokens
    TokenPair tokens = exchangeCode(code);

    // Validate the return URL to prevent open redirect
    if (returnUrl != null && !isAllowedRedirect(returnUrl)) {
        return "redirect:/dashboard"; // Safe default
    }

    return "redirect:" + (returnUrl != null ? returnUrl : "/dashboard");
}

private boolean isAllowedRedirect(String url) {
    try {
        URI uri = new URI(url);

        // Must be relative (no host) or on our domain
        if (uri.getHost() != null) {
            return uri.getHost().equals("app.saas.example");
        }

        // Relative paths: reject path traversal
        if (url.contains("..") || url.contains("//")) {
            return false;
        }

        // Must start with / (absolute path within our domain)
        return url.startsWith("/");
    } catch (URISyntaxException e) {
        return false;
    }
}

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class ConfusedDeputyAndOpenRedirectTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void projectScopeCannotAccessBilling() throws Exception {
        Jwt jwt = Jwt.withTokenValue("test")
            .header("alg", "RS256")
            .claim("sub", "alice")
            .claim("tenant_id", "acme-corp")
            .claim("scope", List.of("project:read", "project:write"))
            .claim("aud", List.of("core-api"))
            .claim("iss", "https://auth.saas.example")
            .issuedAt(Instant.now())
            .expiresAt(Instant.now().plusSeconds(300))
            .build();

        // Can access projects
        mockMvc.perform(get("/api/projects")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isOk());

        // Cannot access billing
        mockMvc.perform(delete("/api/billing/subscription")
                .with(jwt().jwt(jwt)))
            .andExpect(status().isForbidden());
    }

    @Test
    void exactRedirectUriEnforced() throws Exception {
        // Valid redirect URI
        mockMvc.perform(get("/oauth2/authorize")
                .param("client_id", "frontend-shell")
                .param("redirect_uri", "https://app.saas.example/callback")
                .param("response_type", "code"))
            .andExpect(status().is3xxRedirection());

        // Modified redirect URI (path traversal attempt)
        mockMvc.perform(get("/oauth2/authorize")
                .param("client_id", "frontend-shell")
                .param("redirect_uri", "https://app.saas.example/callback/../evil")
                .param("response_type", "code"))
            .andExpect(status().isBadRequest());

        // Completely different domain
        mockMvc.perform(get("/oauth2/authorize")
                .param("client_id", "frontend-shell")
                .param("redirect_uri", "https://evil.example/steal")
                .param("response_type", "code"))
            .andExpect(status().isBadRequest());
    }

    @Test
    void clientSideRedirectRejectsExternalUrl() throws Exception {
        MockHttpSession session = authenticatedSession();

        mockMvc.perform(get("/callback")
                .session(session)
                .param("code", "valid-code")
                .param("returnUrl", "https://evil.example/phishing"))
            .andExpect(redirectedUrl("/dashboard")); // Falls back to safe default
    }
}

The first test is the confused deputy regression test: a token with project scopes cannot access billing resources. The second test validates exact redirect URI matching at the authorization server. Together, they prove that both the token-level and protocol-level confused deputy attacks are prevented.