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:

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/loginThe 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:
- •
Looks up the client registration named
bff-gatewayfrom theClientRegistrationRepository— a bean Spring auto-configured from yourapplication.yml - •
Generates a random
stateparameter for CSRF protection - •
Generates a
code_verifierandcode_challengefor PKCE - •
Stores both in the HTTP session
- •
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=xyz789You 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=abc123Step 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:
- •
Validates the
stateparameter against what was stored in session - •
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- •
Receives back: an access token (JWT, signed with the auth server's RSA private key), a refresh token, and an ID token (OIDC)
- •
Stores everything in the HTTP session tied to the
JSESSIONIDcookie - •
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/jwksSpring 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→JSESSIONIDon domainlocalhost - •
Auth server at
127.0.0.1:9000→JSESSIONIDon domain127.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:readThe 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
- •
Spring Boot Auto-Configuration Explained — How auto-configuration works under the hood. Essential for understanding why beans appear without you creating them. This became critical when I built the common module later.
- •
Spring Tips: Spring Authorization Server— A practical walkthrough of setting up a simple auth server. Also where I confirmed the
localhostvs127.0.0.1cookie domain fix — he runs into the same problem. - •
Implementing an OAuth 2 Authorization Server with Spring Security by Laurentiu Spilca — Goes deeper on registering clients, configuring JWKS, and the token customizer. This is the one I kept pausing and re-watching.
- •
OAuth 2.0 and OpenID Connect (in plain English) — If terms like authorization code, client credentials, PKCE, and OIDC feel like jargon soup, start here. It explains why all of this exists before getting into the how. Gave me the big picture I was missing before diving into implementation.
