DEV Community

Piyush Kumar Singh
Piyush Kumar Singh

Posted on • Originally published at Medium

How OAuth2 Login Works in Spring Security — The Authorization Code Flow Explained

You were redirected to Google. You clicked Allow. You came back logged in. Between those three seconds, six things happened — and most backend developers who have built that button have never traced all of them.

I know because I was one of them. When I first wired up OAuth2 login, it worked on the first attempt. I was relieved. Then a teammate asked me to explain what the state parameter was for, and I didn't have an answer. The feature worked. The understanding wasn’t there.

This article is that explanation — not the config, but the actual flow. What fires, in what order, and where it sits inside Spring Security’s filter chain. If you have read Part 1 of this series on Spring Security internals and Part 2 on JWT with OncePerRequestFilter, this picks up exactly where those left off.

OAuth2 is not what most developers think it is

Before tracing the flow, one distinction is worth getting right.

OAuth2 is an authorization framework, not an authentication protocol. It was designed to let applications access resources on behalf of users — not to verify who a user actually is. When you click “Login with Google,” OAuth2 handles the delegation part: your app is asking Google for permission to act on your behalf. But OAuth2 alone cannot tell your app who you are.

That is what OpenID Connect (OIDC) does. OIDC is a thin identity layer built on top of OAuth2. It adds the id_token — a JWT that carries the user's identity information. When you use "Login with Google" in a Spring Boot app, you are using both: OAuth2 for the delegation mechanism, OIDC for the identity proof.

Most developers conflate these two because the Spring Security config handles both transparently. Understanding the distinction matters when things go wrong — because the errors from each layer are very different.

The 4 roles — mapped to something you already know

Every OAuth2 explanation starts with four roles. Most explanations define them abstractly, which is why they don’t stick. Map them to Login with Google, and they’re immediately obvious.

Resource Owner — you, the user. You own the Google account and the data inside it.

Client — your Spring Boot application. It wants access to the user’s basic profile on your behalf.

Authorization Server — Google’s accounts infrastructure (accounts.google.com). It authenticates the user, shows the consent screen, and issues tokens.

Resource Server — Google’s userinfo API (googleapis.com). It holds the protected data — the email, name, and profile picture your app actually wants.

One thing worth noting: in the Login with Google scenario, Google plays both the Authorization Server and the Resource Server. That is common with large identity providers. In your own systems — if you are building an API protected by OAuth2—your Spring Boot application becomes the Resource Server, and a separate authorization server (Keycloak, Okta, Auth0) handles the tokens.

The Authorization Code Flow—What Happened During That Redirect

There are several OAuth2 flows. The one you use for web applications — and the only one worth knowing in 2026 — is the Authorization Code Flow. The others (Implicit, Resource Owner Password) are either deprecated or meant for machine-to-machine scenarios.

Here is each step:

Step 1 — User clicks Login with Google.
Your Spring Boot app constructs a URL pointing to Google’s authorization endpoint, including your client_id, the redirect_uri it wants Google to return to, the scope (what access you're requesting — typically openid profile email), a state parameter (more on this shortly), and response_type=code. The user's browser is redirected to that URL.

Step 2 — Google shows the consent screen.
Google authenticates the user (if not already logged in) and shows them what your app is requesting access to. The user clicks Allow.

Step 3 — Google redirects back with a code.
Google redirects the user’s browser to your redirect_uri with an authorization code appended to the URL. This code is short-lived — typically valid for about 60 seconds — and can only be used once.

Notice what Google did not send: an access token. The code travels through the browser, which means it passes through browser history, HTTP logs, and referrer headers. Sending the actual token here would be a security hole. The code is intentionally useless on its own.

Step 4 — Your app exchanges the code for tokens.
This step happens entirely server-to-server. Your Spring Boot backend takes the code and calls Google’s token endpoint directly — not through the browser. It sends the code along with your client_secret. Google verifies both and issues the tokens.

This server-to-server exchange is the entire security model of the Authorization Code Flow. The client_secret never touches the browser. The actual token never travels through a URL. The browser only ever saw a short-lived code.

Step 5 — Google returns an access token and an ID token.
The access_token is what you use to call Google's APIs on the user's behalf. The id_token is an OIDC JWT — it contains the user's identity: their Google ID, email, name, and profile picture. If you read Part 2 of this series, this JWT follows the same structure — header, payload, signature — that OncePerRequestFilter was validating.

Step 6 — Your app reads the id token, loads the user, and populates the SecurityContext.
Spring Security decodes theid_token, extracts the user's details, and stores an authentication object in SecurityContextHolder. From here, the rest of your application sees a fully authenticated user — through @AuthenticationPrincipal, SecurityContextHolder.getContext(), or a principal in your controllers. The same as username-password login.

Where OAuth2 plugs into Spring Security’s filter chain

This is the part no other OAuth2 tutorial can show you — because they did not write Part 1 first.

In Part 1, you traced how FilterChainProxy selects a SecurityFilterChain, how authentication filters extract credentials, how ProviderManager delegates to an AuthenticationProvider, and how everything ends up in SecurityContextHolder.

OAuth2 does not replace that chain. It extends it with two new filters.

OAuth2AuthorizationRequestRedirectFilter — this fires when a user hits /oauth2/authorization/google (or whichever provider you configured). It builds the full authorization URL — including generating and storing the state parameter — and redirects the user's browser to Google. This is what runs in Step 1 and 2 of the flow above.

OAuth2LoginAuthenticationFilter — this fires when Google redirects back to your /login/oauth2/code/google callback URL. It reads the authorization code from the request, verifies the state parameter, calls Google's token endpoint to exchange the code (Step 4), receives the tokens (Step 5), and creates an OAuth2LoginAuthenticationToken.

That token then goes to ProviderManager — the same ProviderManager you already know from Part 1. ProviderManager loops through its registered providers, finds OidcAuthorizationCodeAuthenticationProvider, and calls it.

OidcAuthorizationCodeAuthenticationProvider validates the id_token JWT signature, checks its expiry and claims, and calls OAuth2UserService to load or create your application's user. Then it returns a fully authenticated Authentication object.

That object goes into SecurityContextHolder. Same destination as username-password login. Different path to get there.

OAuth2UserService — where your app takes over
Spring Security handles everything up to this point automatically. OAuth2UserService is where your application steps in.

After validating the id_token, Spring Security calls OAuth2UserService.loadUser() to turn Google's user attributes into something your application understands. The default implementation, DefaultOAuth2UserService, fetches the user's profile from Google's userinfo endpoint and returns an OAuth2User with those attributes.

In most production systems, you need more than that. Google gives you a name and email. Your database has a User entity with an internal ID, roles, preferences, and account status. You override DefaultOAuth2UserService to bridge that gap:

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Autowired
    private UserRepository userRepository;
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String email = oAuth2User.getAttribute("email");
        // load or create the user in your own DB
        User user = userRepository.findByEmail(email)
            .orElseGet(() -> createNewUser(oAuth2User));
        return new DefaultOAuth2User(
            Collections.singleton(new SimpleGrantedAuthority(user.getRole())),
            oAuth2User.getAttributes(),
            "email"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This is not an OAuth2 concept — it is a Spring Security hook. But it is where most “Login with Google” implementations actually live. The config wires up the flow. This class decides what your application does with the identity Google hands back.

The state parameter — the detail almost everyone skips

Every authorization request your app sends to Google includes a state parameter — a randomly generated value that Spring Security creates and stores in the session. When Google redirects back to your app, it returns that same state value unchanged.

Your app verifies that the returned state matches the one it stored before doing anything else.

If you skip this check, an attacker can craft a malicious link that starts a login flow and redirects to your callback with their own authorization code. The victim clicks the link, your app completes the exchange, and the victim is now logged into the attacker’s account — with full access to whatever your app shows an authenticated user.

This is a CSRF attack against OAuth2. Spring Security handles state generation and validation automatically. You do not need to write this logic. But if you ever build an OAuth2 flow manually — or review one built without a framework — missing state validation is the first thing to check.

When NOT to use OAuth2

Do not use the Implicit Flow. It was removed from OAuth2.1. It sent tokens directly in the URL, which means tokens in browser history, server logs, and referrer headers. There is no legitimate reason to use it today.

Do not store access tokens in localStorage. XSS vulnerabilities can read localStorage trivially. Use HttpOnly cookies, or keep tokens server-side in the session, where JavaScript cannot touch them.

Do not use OAuth2 when you do not need it. If your application has its own user database and no external identity provider, JWT authentication — which you traced in Part 2 — is simpler. You own the entire flow, you are not dependent on a third party’s uptime, and there is no redirect dance to debug. OAuth2 earns its complexity only when the third-party identity or the delegated access to third-party resources is genuinely valuable.

Do not forget token expiry. Access tokens are short-lived by design. If you store a token and reuse it without checking expiry, your users will get silent authentication failures at the worst possible moment. Build refresh token logic from the start, not as an afterthought.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.