Skip to main content

Command Palette

Search for a command to run...

Techniques to persist and retrieve client_id in Spring Security’s RequestCache during OAuth redirects

When an OAuth authorization request hits your Authorization Server without an authenticated user, Spring Security politely tucks away the incoming HTTP request and sends the user to your login page. After authentication, it pulls that request bac...

Published
7 min read
Techniques to persist and retrieve client_id in Spring Security’s RequestCache during OAuth redirects
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. Why RequestCache is the right pocket for client_id

In Spring Security, RequestCache stores the original request that triggered authentication. For OAuth, that original request is typically GET /oauth2/authorize?response_type=code&client_id=…&redirect_uri=…. When the user is unauthenticated, the Authorization Endpoint defers the request, redirects to login, and later resumes. Persisting client_id in the same place ensures your login page can tailor UI (branding, tenant theme, hints) and your post-login handler can validate and complete the authorization without guessing or re-parsing arbitrary headers. Relying on RequestCache also keeps you away from fragile ad-hoc cookies and avoids mixing concerns with AuthorizationRequestRepository used by OAuth client logins. The result is a simple contract: if a request caused the login, you can always recover its parameters, including client_id.

1.1 The minimal custom RequestCache that snapshots client_id

By default, HttpSessionRequestCache already stores query parameters in a SavedRequest. However, teams often add pre-auth filters, internal redirects, or custom login controllers that lose sight of those parameters. A tiny decorator makes the intent explicit and gives you a clean API to fetch the client_id anywhere in the login journey.

package com.example.auth.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;

public class ClientAwareRequestCache implements RequestCache {

public static final String SESSION_ATTR = "OAUTH_CLIENT_ID";

private final HttpSessionRequestCache delegate = new HttpSessionRequestCache();

@Override
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
delegate.saveRequest(request, response);
String clientId = request.getParameter("client_id");
if (clientId != null && !clientId.isBlank()) {
request.getSession(true).setAttribute(SESSION_ATTR, clientId);
}
}

@Override
public SavedRequest getRequest(HttpServletRequest request, HttpServletResponse response) {
return delegate.getRequest(request, response);
}

@Override
public void removeRequest(HttpServletRequest request, HttpServletResponse response) {
delegate.removeRequest(request, response);
// Optional: clear the shadow copy after use to avoid leaking between tabs.
request.getSession(false);
if (request.getSession(false) != null) {
request.getSession(false).removeAttribute(SESSION_ATTR);
}
}

/* Helper: prefer SavedRequest params, then the session snapshot. /
public static String resolveClientId(HttpServletRequest request, SavedRequest saved) {
if (saved != null) {
String[] values = saved.getParameterValues("client_id");
if (values != null && values.length > 0) return values[0];
}
Object fromSession = request.getSession(false) == null ? null
: request.getSession(false).getAttribute(SESSION_ATTR);
return fromSession instanceof String ? (String) fromSession : null;
}
}

This decorator stores the client_id defensively in the session when the redirect happens and exposes a tiny utility to read it back from the authoritative source: the SavedRequest. The session copy exists purely as a belt-and-suspenders fallback for custom flows where the SavedRequest might not be in scope.

1.2 Wiring it into Spring Security for both the Authorization Endpoint and login

You want the same cache to be used whenever Spring decides to save a request for authentication. That means attaching it to the filter chain that secures /login, /oauth2/authorize, and your consent page.

package com.example.auth.config;

import com.example.auth.security.ClientAwareRequestCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

@Bean
RequestCache requestCache() {
return new ClientAwareRequestCache();
}

@Bean
SecurityFilterChain authServerChain(HttpSecurity http, RequestCache requestCache) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/assets/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) // if you have resource endpoints
.requestCache(rc -> rc.requestCache(requestCache));
return http.build();
}
}

Two subtleties matter here. First, by registering a bean of type RequestCache and injecting it into the chain, you guarantee the same cache instance is used across the redirect/save/resume cycle. Second, leaving formLogin on defaults is fine; Spring will still call your RequestCache on entry and exit. If you host Spring Authorization Server, ensure this chain also protects /oauth2/authorize so the cache is engaged when the unauthenticated request arrives.

1.3 Surfacing client_id on the login page and validating it post-login

Most teams want to brand the login page per client, or at least show a friendly name. Read the client_id in your MVC controller using the helper, fall back to a safe default, and never trust it blindly.

package com.example.auth.web;

import com.example.auth.security.ClientAwareRequestCache;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

private final HttpSessionRequestCache cache = new HttpSessionRequestCache();

@GetMapping("/login")
public String login(Model model, HttpServletRequest request) {
SavedRequest saved = cache.getRequest(request, null);
String clientId = ClientAwareRequestCache.resolveClientId(request, saved);
String displayName = lookupDisplayNameSafely(clientId); // e.g., fetch from registered clients
model.addAttribute("clientId", clientId);
model.addAttribute("clientDisplay", displayName);
return "login"; // Thymeleaf/Freemarker/etc.
}

private String lookupDisplayNameSafely(String clientId) {
if (clientId == null) return "Unknown application";
// Resolve from your ClientRepository/RegisteredClientRepository
return switch (clientId) {
case "docs-portal" -> "Docs Portal";
case "video-app" -> "Video App";
default -> "Partner Application";
};
}
}

After authentication succeeds, Spring continues the saved request to the Authorization Endpoint. If you need the client_id inside a success handler (for audit or custom consent), the exact same helper pattern works there too. The golden rule is to always cross-check that client_id exists in your RegisteredClientRepository before using it for anything user-visible.

2. A deeper example: post-login continuation, consent prefill, and edge cases

A realistic flow often includes a consent page that needs client_id, scopes, and branding. You can retrieve the same client_id from the SavedRequest when your consent endpoint is rendered, and cross-reference scopes to pre-check toggles for a smoother experience. You also need to handle multi-tab browsing and the occasional pre-auth redirect loop with grace.

package com.example.auth.web;

import com.example.auth.security.ClientAwareRequestCache;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ConsentController {

private final HttpSessionRequestCache cache = new HttpSessionRequestCache();

@GetMapping("/oauth2/consent")
public String consent(Model model, HttpServletRequest request) {
SavedRequest saved = cache.getRequest(request, null);
String clientId = ClientAwareRequestCache.resolveClientId(request, saved);
// Extract scopes from the SavedRequest as well
String[] scopes = saved != null ? saved.getParameterValues("scope") : null;

model.addAttribute("clientId", clientId);
model.addAttribute("scopes", scopes != null ? String.join(" ", scopes) : "");
// Render a branded consent page with prechecked scopes.
return "consent";
}
}

This example leans on the facts that SavedRequest keeps the original query parameters and that your ClientAwareRequestCache snapshot is available even if an intermediate custom redirect momentarily detaches the saved request. If your flow uses a custom AuthenticationSuccessHandler to centralize post-login behavior, you can fetch the same values there and route users to a first-party consent page with everything pre-filled.

2.1 Security and correctness notes that prevent foot-guns

It is tempting to treat client_id as harmless metadata, but the wrong value can at best confuse the user and at worst mislead them into consenting to the wrong app. Always resolve client_id against your RegisteredClientRepository and derive all branding and redirect URIs from the registered record, not from request parameters. Clear the session snapshot once it’s no longer needed to avoid cross-tab bleed. Prefer SavedRequest as the source of truth; the session attribute is merely a safety net for custom detours. If you use reverse proxies or nests of internal redirects, ensure the first saved request you read is genuinely the authorization one — not a themed /login?theme=dark page you saved later by accident. Lastly, if you rely on PKCE or state/nonce parameters, keep them in their designated storage (e.g., Spring Authorization Server’s context) rather than mixing them into your RequestCache helpers.

2.2 Testing the behavior with MockMvc so it won’t regress later

The best way to keep this robust is a focused MVC test that asserts client_id survives the redirect to /login and is visible in the model. This makes refactors safe when you later add SSO, theming, or change the login controller.

package com.example.auth.web;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.security.test.context.support.WithAnonymousUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(controllers = { LoginController.class })
class LoginFlowTests {

@Autowired
MockMvc mvc;

@Test
@WithAnonymousUser
void clientIdIsExposedOnLoginAfterRedirect() throws Exception {
// Simulate the original authorize call (unauthenticated)
mvc.perform(get("/oauth2/authorize")
.param("response_type", "code")
.param("client_id", "docs-portal")
.param("redirect_uri", "https://client.example/cb"))
.andExpect(status().is3xxRedirection());

// Now load the login page and verify model attributes
mvc.perform(get("/login"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("clientId"))
.andExpect(model().attribute("clientId", "docs-portal"));
}
}

This test doesn’t stand in for a full Authorization Server integration test, but it nails the contract we care about: the request that triggered authentication carries client_id, and our login page can read it back without guesswork.

If you want a mental picture while debugging, imagine the RequestCache as a coat check tag attached to your user’s original HTTP request. The login page shouldn’t rummage through the entire coat closet — it should show the tag number (client_id) on the counter, then hand the exact coat back after the user proves they own it.

Have questions about edge cases, Spring Authorization Server wiring, or consent page composition? Drop them in the comments below — I’ll help you thread the needle without dropping any state or nonce along the way.

Read more at : Techniques to persist and retrieve client_id in Spring Security’s RequestCache during OAuth redirects

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!