Skip to main content

Half of My OAuth2 Login Flow Is Code I Never Wrote

M
Markian MumbainTech blog
10 min read·Mar 24, 2026
OAuthOpenIDPKCECookiejwks
Monolisa
Monalisa,1503–1506,Leonardo da Vinci

Over-Engineered on Purpose — Part 7: Setting Up an Authorization Server and Understanding What Spring Does Behind Your Back

This is Part 7 of a series where I'm building a microservice platform from scratch. Part 6 covers why monolith security assumptions break in microservices. Full codebase on GitHub.

At the end of Part 6, I'd reached a conclusion: I needed to stop doing security the monolith way. No more shared secrets. No more each-service-validates-its-own-way. I needed a centralized authorization server that issues tokens, and services that verify them using public keys. So I set up Spring Authorization Server. And then spent the next several days trying to understand what it was actually doing, because about half the login flow happens in code I never wrote. Spring's OAuth2 support is powerful. It's also one of the most implicit frameworks I've ever worked with. Filters get registered that you didn't create. Redirects happen that you didn't configure. Tokens get exchanged in the background while you're looking at a login page. Understanding what's implicit and what's explicit is the difference between "it works and I don't know why" and "I can debug this when it breaks."

The New Architecture

Here's what the security setup looks like now:

image
image

The key principle: the browser never sees tokens. It only ever gets a JSESSIONID session cookie. The BFF holds the access tokens, refresh tokens, and JWTs server-side. The browser talks to the BFF with cookies, and the BFF talks to services with JWTs.

The Auth Server is a new dedicated service. Its only job is authenticating users and issuing tokens. It holds the RSA private key for signing. Everyone else gets the public key.

Setting Up the Auth Server

The authorization server is a Spring Boot application with spring-boot-starter-oauth2-authorization-server. The first thing it needs is registered clients — the applications it trusts.

@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder) {
    RegisteredClient bff = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("bff-gateway")
        .clientSecret(passwordEncoder.encode("bff-gateway-secret"))
        .clientAuthenticationMethod(
            ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(
            AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(
            AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri(redirectUri)
        .scope("openid")
        .scope("profile")
        .scope("read")
        .scope("write")
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofHours(1))
            .refreshTokenTimeToLive(Duration.ofDays(7))
            .build())
        .clientSettings(ClientSettings.builder()
            .requireAuthorizationConsent(false)
            .build())
        .build();
    return new InMemoryRegisteredClientRepository(bff);
}

This registers the BFF as a trusted client that can use the Authorization Code flow for user logins and Refresh Token flow for token renewal. The client ID and secret here must match what's configured on the BFF side — they're how the BFF proves its identity to the auth server. I also added a token customizer to include user details in the JWT:

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
    return context -> {
        if (context.getTokenType().getValue().equals("access_token")) {
            AuthorizationGrantType grantType =
                context.getAuthorizationGrantType();
            if (grantType.equals(
                    AuthorizationGrantType.CLIENT_CREDENTIALS)) {
                // Service-to-service token
                context.getClaims().claim("token_type", "SERVICE");
            } else if (grantType.equals(
                    AuthorizationGrantType.AUTHORIZATION_CODE)) {
                // User token — fetch details from User Service
                context.getClaims().claim("token_type", "USER");
                var user = userServiceStub.getUserByEmail(...);
                context.getClaims().claim("user_id", user.getId());
                context.getClaims().claim("role", user.getRole().name());
                context.getClaims().claim("email", user.getEmail());
            }
        }
    };
}

This is where the auth server already starts talking to other services. When a user logs in, the token customizer makes a gRPC call to the User Service to fetch the user's ID, email, and role, then bakes those claims into the JWT. The auth server is both an OAuth2 provider and a client of the User Service.

The OAuth2 Authorization Code Flow (And Everything Spring Does Implicitly)

This is where it gets interesting. The Authorization Code flow is the standard for browser-based login, and Spring automates most of it. The problem is that if you don't understand the implicit parts, debugging becomes guesswork.

Let me walk through the complete flow, marking what's implicit and what's explicit.

Step 1: User clicks Login

The user clicks "Sign In" in the frontend. The frontend redirects to the BFF:

GET http://localhost:8080/api/v1/auth/login

The BFF's auth controller handles this — this part is explicit:

@GetMapping("/login")
public void login(HttpServletResponse response) {
    response.sendRedirect("/oauth2/authorization/bff-gateway");
}

Step 2: Spring takes over (IMPLICIT)

Nobody writes a controller for /oauth2/authorization/bff-gateway. Spring's OAuth2AuthorizationRequestRedirectFilter intercepts it automatically. This filter was registered the moment you wrote .oauth2Login(...) in your security config.

What it does behind the scenes:

  1. Looks up the client registration named bff-gateway from the ClientRegistrationRepository — a bean Spring auto-configured from your application.yml

  2. Generates a random state parameter for CSRF protection

  3. Generates a code_verifier and code_challenge for PKCE

  4. Stores both in the HTTP session

  5. Redirects the browser to the auth server:

HTTP 302 → <http://127.0.0.1:9000/oauth2/authorize>
  ?response_type=code
  &client_id=bff-gateway
  &scope=openid profile email
  &redirect_uri=http://localhost:8080/login/oauth2/code/bff-gateway
  &state=abc123
  &code_challenge=xyz789

You didn't write any of this. It happened because Spring found an OAuth2 client registration in your config and wired up the filter.

Step 3: Auth Server login page

The browser is now at the auth server. The user sees a login form, enters their email and password. The auth server validates credentials — in my case, it calls the User Service via gRPC to verify the user exists and the password hash matches.

Step 4: Auth Server issues an authorization code

Credentials are valid. The auth server creates a short-lived, single-use authorization code and redirects back to the BFF:

HTTP 302 → http://localhost:8080/login/oauth2/code/bff-gateway
  ?code=AUTH_CODE_HERE
  &state=abc123

Step 5: Spring exchanges the code for tokens (IMPLICIT)

This is the most important implicit step. Spring's OAuth2LoginAuthenticationFilter intercepts the callback URL. Again, you never wrote a controller for /login/oauth2/code/bff-gateway. The filter:

  1. Validates the state parameter against what was stored in session

  2. Makes a server-to-server POST to the auth server's token endpoint — the browser never sees this:

POST http://127.0.0.1:9000/oauth2/token
Authorization: Basic base64(bff-gateway:bff-gateway-secret)
grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=http://localhost:8080/login/oauth2/code/bff-gateway
&code_verifier=ORIGINAL_VERIFIER
  1. Receives back: an access token (JWT, signed with the auth server's RSA private key), a refresh token, and an ID token (OIDC)

  2. Stores everything in the HTTP session tied to the JSESSIONID cookie

  3. Calls the success handler which redirects the browser to the frontend

The user is now logged in. The browser has a session cookie. The BFF has the tokens. The frontend has no idea any of this happened.

What's explicit vs implicit

This tripped me up enough that I want to lay it out clearly:

Implicit — the authorization redirect filter, the code-for-token exchange, storing tokens in the session, resolving JSESSIONID to a user identity, fetching JWKS public keys, refreshing expired tokens.

Explicit — CORS config, which endpoints are public vs protected, what to do on auth failure (401 vs 302), where to redirect after login, custom JWT claims via the token customizer.

When something goes wrong, knowing which category your problem falls in determines where you look. If tokens aren't being exchanged, you're debugging implicit Spring filter behavior. If your custom claims are wrong, you're debugging your explicit customizer.

JWKS: How Services Verify Tokens Without the Secret

This is the piece that replaces the shared secret from Part 6.

When the auth server starts, it generates an RSA key pair:

@Bean
public JWKSource<SecurityContext> jwkSource() {
    KeyPair keyPair = generateRsaKey();
    RSAKey rsaKey = new RSAKey.Builder(publicKey)
        .privateKey(privateKey)
        .keyID(UUID.randomUUID().toString())
        .build();
    return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}

The private key signs tokens. It never leaves the auth server. The public key is published at /oauth2/jwks — a standard URL that anyone can fetch.

When a microservice needs to verify a JWT for the first time, it fetches the public key from the JWKS endpoint, caches it, and uses it to verify signatures on every subsequent request. No shared secrets. No network call per request. Fetch once, verify forever (until keys rotate).

# Any service that validates tokens
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://127.0.0.1:9000
          jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks

Spring auto-configures a JwtDecoder from these properties. On the first request, it fetches the public key. On every request after that, it validates the token signature, checks expiration, and verifies the issuer — all without calling the auth server.

This is the zero-trust model I mentioned in Part 6. Each service validates tokens independently. A compromised service can't forge tokens because it doesn't have the private key. It can only verify them with the public key — which is, by design, public.

The Cookie Domain Collision (A Debugging Story)

I had the auth server and BFF running locally. Login would redirect to the auth server, I'd enter credentials, and... it would loop back to the login page. Session lost. Over and over.

The problem: both the BFF (localhost:8080) and auth server (localhost:9000) are on localhost. Cookies are scoped by domain, not port. So both servers were setting a JSESSIONID cookie on the localhost domain, and they were overwriting each other. The auth server would create a session, set a cookie, redirect to the BFF, and the BFF would set its own JSESSIONID, destroying the auth server's session.

The fix was embarrassingly simple. Run the auth server on 127.0.0.1:9000 instead of localhost:9000. The browser treats localhostand 127.0.0.1 as different domains, so cookies don't collide:

  • BFF at localhost:8080 JSESSIONID on domain localhost

  • Auth server at 127.0.0.1:9000 JSESSIONID on domain 127.0.0.1

Two cookies. No conflict. Login works.

Service-to-Service: The Other Kind of Token

User login is only half the story. What about when the Booking Service needs to call the Catalog Service to check machine availability? There's no user clicking a login button. There's no browser. It's just one service calling another.

This is where Client Credentials come in — a separate OAuth2 flow designed for machine-to-machine authentication. The service sends its client ID and secret directly to the auth server's token endpoint and gets back an access token:

POST http://auth-server:9000/oauth2/token
  grant_type=client_credentials
  client_id=booking-service
  client_secret=booking-secret
  scope=internal,catalog:read

The auth server verifies the credentials, issues a JWT with token_type: SERVICE instead of token_type: USER, and the Booking Service uses this token to call the Catalog Service.

I registered each service as a client on the auth server:

RegisteredClient bookingService = RegisteredClient
    .withId(UUID.randomUUID().toString())
    .clientId("booking-service")
    .clientSecret(passwordEncoder.encode("booking-secret"))
    .clientAuthenticationMethod(
        ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
    .authorizationGrantType(
        AuthorizationGrantType.CLIENT_CREDENTIALS)
    .scope("internal")
    .scope("user:read")
    .scope("catalog:read")
    .tokenSettings(TokenSettings.builder()
        .accessTokenTimeToLive(Duration.ofMinutes(30))
        .build())
    .build();

Now there are two types of tokens in the system. User tokens — issued through the Authorization Code flow when a human logs in. Service tokens — issued through Client Credentials when a service needs to talk to another service. The auth server customizes the claims differently for each:

// User token
{
  "token_type": "USER",
  "user_id": "550e8400-...",
  "email": "john@example.com",
  "role": "CUSTOMER"
}
// Service token
{
  "token_type": "SERVICE",
  "client_id": "booking-service",
  "scope": "internal user:read catalog:read"
}

Both are signed with the same private key. Both are verified with the same public key. But they carry different information and represent different levels of trust.

Where Things Stand

The authorization server is running. It issues user tokens through the Authorization Code flow and service tokens through Client Credentials. The BFF handles the OAuth2 dance and stores tokens in the session. Every microservice can verify tokens independently using the public key from JWKS.

But the tokens aren't flowing through gRPC calls yet. The BFF has the user's JWT in its session — how does it get attached to outgoing gRPC requests? When the Booking Service receives a call, how does it validate the token and extract user info? When the Booking Service needs to call the Catalog Service, how does it decide whether to forward the user's token or get its own service token?

That's the gRPC interceptor infrastructure — and it's Part 8.

Resources That Helped

Reviews (0)

No reviews yet. Be the first to share your thoughts!