← Java Internals explorer

Payment Security Journey

SecurePay: three Spring Boot services (Authorization Server :9000, gateway A :8080, data service B :8081). Part 1 follows one real request end to end. Part 2 explains every piece of code, why it was written, and what data travels where.

1 · Live request journey

Pick a scenario, then click a box or use Next. The right panel shows exactly what changed after each step (green = added, yellow = changed, red = removed). Arrow keys also work.

payment-service-a :8080 (gateway) payment-service-b :8081 (data + ownership) ClientPostman Auth Serverissues JWT · JWKS Security filtersBearerTokenAuthenticationFilter JwtDecoderNimbusJwtDecoder PaymentController@RestController PaymentServiceImplwebClient.post() / get() WebClienttoken relay · 2s / 3s timeouts GlobalExceptionHandler@RestControllerAdvice Security filtersBearerTokenAuthenticationFilter JwtDecoderown JWKS cache PaymentController@Valid @RequestBody PaymentServiceImplcurrentUser() → owner PaymentRepositoryJpaRepository · findByOwner GlobalExceptionHandler404 · 409 · 400 H2 DatabasePAYMENTS table

State after this step

+ added~ changed− removed

Dashed arrows are the one-time-per-key-set fetch of the auth server's public keys (/oauth2/jwks): A and B each cache them and verify tokens locally, so the auth server is not called on every request. The dev profile makes a fresh key on every auth-server start, so tokens issued before a restart stop verifying.

2 · Big picture

Who does what
Postman ──login + consent──► Auth Server :9000 ──JWT (RS256, 5 min)──► Postman
Postman ──Authorization: Bearer JWT──► Service A :8080 ──same JWT──► Service B :8081 ──► H2 file DB
ServiceJobKnows about
payment-authorization-server :9000Logs users in, asks for consent, issues signed JWTs, publishes its public keysUsers (nithin, alice), the client payment-client, the signing key
payment-service-a :8080Gateway. Checks the token and scope, forwards the call to B with the same token, maps B's errorsNo database. No payment logic
payment-service-b :8081Stores payments in H2, checks the token itself, enforces ownershipThe PAYMENTS table

Three ideas that explain the whole design

3 · How a token is born (Authorization Server)

The login → consent → code → token flow, and the code behind each step
1. Client opens   GET /oauth2/authorize?response_type=code&client_id=payment-client&scope=payment.read payment.write&redirect_uri=...
2. Not logged in  → 302 /login           (LoginUrlAuthenticationEntryPoint)
3. User posts     POST /login  username + password + _csrf   → session cookie
4. Back to        GET /oauth2/authorize  → consent page (first time) or a redirect with ?code=... (consent remembered)
5. User approves  POST /oauth2/authorize  client_id + state + scope checkboxes → 302 redirect_uri?code=XYZ
6. Client swaps   POST /oauth2/token  Basic payment-client:secret + grant_type=authorization_code + code
7. Server answers {"access_token":"eyJ...", "token_type":"Bearer", ...}   (a signed JWT)

What each bean in SecurityConfig (auth server) does, and why it exists

Bean / methodWhat it doesWhy it's written this way
authorizationServerSecurityFilterChain @Order 1Owns the OAuth URLs (/oauth2/authorize, /oauth2/token, /oauth2/jwks, OIDC discovery). Browsers without a login are sent to /loginThe OAuth protocol is implemented by filters, not controllers. securityMatcher(getEndpointsMatcher()) makes this chain apply only to those URLs
defaultSecurityFilterChain @Order 2Everything else: login page via formLogin, all other pages need a loginCatches what chain 1 didn't claim. Provides the form that step 3 posts to
userDetailsServiceInMemoryUserDetailsManager(nithin, alice), password passwordThe people who log in. Two users exist so ownership can be tested: the JWT sub is the username
registeredClientRepositoryOne client payment-client / secret, authorization-code grant, scopes payment.read + payment.write, consent required, token lifetime 5 minutesThe application allowed to ask for tokens. Redirect URI is matched exactly, so a code can only be sent to Postman's callback
devJwkSource @Profile("dev")Generates an RSA-2048 key in memory at startup (kid = random UUID)Zero setup for local work. Downside: a restart makes a new key, so old tokens stop verifying
jwkSource(@Value KEYSTORE_PATH, KEYSTORE_PASSWORD) @Profile("!dev")Loads the key pair with alias securepay from a PKCS12 file, fixed kid securepay-1Tokens survive restarts and the private key stays out of the repo. It refuses to start without the two variables
jwtDecoder(jwkSource)Lets the auth server read tokens sent to it (e.g. /userinfo)Takes the JWKSource by type, so it works with either signing bean. A and B build their own decoder from issuer-uri
authorizationServerSettingsissuer("http://localhost:9000")Written into every token's iss. Fixed so localhost vs 127.0.0.1 can't create two issuers

What is inside the token

header : {"alg":"RS256","kid":"…"}
claims : {"iss":"http://localhost:9000","sub":"nithin",
          "scope":["payment.read","payment.write"],"iat":…,"exp": iat + 300, …}
signature = RSA-sign(header.claims, private key)      ← only the auth server has the private key

4 · Service A (the gateway)

Every piece of A and why it exists
Class / methodWhat it doesWhy it's written this way
SecurityConfig.securityFilterChainOpen: /actuator/health, /error. POST /payments/** needs SCOPE_payment.write, GET /payments/** needs SCOPE_payment.read. Everything else needs a token. Stateless, CSRF offRules are checked top to bottom, first match wins, so the specific rules sit before anyRequest().authenticated(). SCOPE_ is the prefix Spring adds to each scope. No session or cookie carries identity, so CSRF protection has nothing to protect
.oauth2ResourceServer(o -> o.jwt(...))Installs the JWT filter and builds a decoder from spring.security.oauth2.resourceserver.jwt.issuer-uriOne line turns A into a resource server: it fetches the JWKS once, caches it, and checks signature, exp and iss on every request
PaymentControllerPOST /payments, GET /payments/{id}, GET /payments. Each just calls PaymentServiceControllers stay thin. A has no @Valid: validation is B's job, and A passes B's 400 through
PaymentServiceImplCalls B with webClient and .block(). In getPayment, a 404 from B is caught and rethrown as B's PaymentNotFoundException.block() keeps the call on the request thread, which is what lets the token relay find the caller's JWT. Translating the 404 gives A one clean exception type to map
WebClientConfig.webClientReactor Netty client with 2 s connect and 3 s response timeouts, base URL http://localhost:8081, and ServletBearerExchangeFilterFunctionWithout the connector the timeouts are silently ignored and a hung B would hold A's threads. The filter copies the caller's bearer token onto the outgoing call, so B sees the real user
GlobalExceptionHandlerTurns exceptions into JSON: 404 for not-found, 401/403 from B, B's own status for anything else, 503 when B is unreachable, 400 for bad JSON, 404 for unknown routes, 500 as the last resortSpring picks the most specific handler. The not-found handler was once commented out, so B's 404 fell into the catch-all and came out as a 500

Gotcha in WebClientConfig

The method takes WebClient.Builder builder but returns builder() (a static import), which creates a new builder. The injected one is never used. It works, but to use Spring Boot's builder (which carries tracing instrumentation) write builder.baseUrl(...)… without parentheses and drop the static import.

5 · Service B (data + ownership)

Every piece of B and why it exists
Class / methodWhat it doesWhy it's written this way
SecurityConfig.securityFilterChainSame rules as A (scopes, JWT, stateless)B is a full resource server on its own, because it can be called directly
h2ConsoleSecurityFilterChain @Profile("dev") · @Order(1)Matches only the H2 console (PathRequest.toH2Console()), permits it, allows framing from the same originThe console has its own login and can't carry a bearer token. Confining these relaxations to a dev-only chain keeps the API strict. Outside dev this chain doesn't exist
Payment entityid, accountNumber, amount, ownerowner stores the creator's JWT sub
PaymentRepositoryJpaRepository + findByOwner(String)Spring Data builds WHERE owner = ? from the method name. No SQL written
PaymentControllerPOST is @Valid @RequestBody, GET by id, GET allBean Validation rejects bad input (e.g. amount ≤ 0) before any business code runs
PaymentServiceImpl.currentUser()SecurityContextHolder.getContext().getAuthentication().getName()For a JWT login getName() is the sub. The owner comes from the verified token, never from the request body, so a client can't claim someone else's payment
create @TransactionalValidates, rejects a duplicate id, sets the owner, savesOne transaction for check + insert. A simultaneous duplicate is caught by the database's primary key and mapped to 409
getPaymentByIdfindById(id).filter(x -> currentUser().equals(x.getOwner())).orElseThrow(PaymentNotFoundException)Someone else's payment behaves exactly like a missing one: 404, not 403. A 403 would confirm that the id exists
getAllPaymentsfindByOwner(currentUser())The list only ever contains the caller's payments
GlobalExceptionHandler404 PAYMENT_NOT_FOUND, 409 DUPLICATE_PAYMENT (also for a database key violation), 400 validation / malformed bodyOne JSON shape for every error: timestamp, status, error, message, path

6 · Data at each hop

What the data looks like as it travels (POST /payments)
HopWhat travels
Postman → APOST http://localhost:8080/payments
Authorization: Bearer eyJ… · Content-Type: application/json
body {"paymentId":"OWN-1","accountNumber":"acc-123","amount":25.00}
Inside AJackson builds PaymentRequest(paymentId, accountNumber, amount:BigDecimal). The verified token becomes a JwtAuthenticationToken in SecurityContextHolder (authorities SCOPE_payment.read, SCOPE_payment.write)
A → BPOST http://localhost:8081/payments with the same Authorization: Bearer header and the same JSON body, re-serialized from PaymentRequest
Inside BToken verified again → validated request → Payment(id, accountNumber, amount, owner="nithin")
B → H2insert into payments (payment_id, account_number, amount, owner) values ('OWN-1','acc-123',25.00,'nithin')
B → A → Postman200 {"paymentId":"OWN-1","status":"SUCCESS","message":"Payment created Successfully"}. A reads it into PaymentResponse and returns it unchanged

The row that decides ownership

PAYMENT_ID | ACCOUNT_NUMBER | AMOUNT | OWNER
OWN-1      | acc-123        | 25.00  | nithin        ← nithin's token: 200   ·   alice's token: 404

7 · Who returns which error

Status codes, who produces them, and why
StatusWhenProduced by
401No token, garbage token, expired tokenA's (or B's) JWT filter → BearerTokenAuthenticationEntryPoint; the controller is never reached
403Valid token but the wrong scope (e.g. read-only token on POST)AuthorizationFilter → BearerTokenAccessDeniedHandler in A
404Payment missing or owned by someone elseB: PaymentNotFoundException → 404. A catches B's 404 and returns its own 404. Unknown route: A/B NoResourceFoundException
409Duplicate payment idB (DuplicatePaymentException or a database key violation); A passes it through
400Validation failure (amount ≤ 0), malformed JSONB validates and A passes it through; A itself rejects broken JSON
503B down, connection refused, connect or response timeoutA: WebClientRequestException → bDown
500Anything unexpectedThe catch-all handler. Seeing this for a not-found means a handler is missing

8 · Profiles: dev vs default

What changes between local development and everything else
dev (local)default
Auth server signing keyGenerated in memory. Tokens die when the auth server restartsPKCS12 keystore. Needs KEYSTORE_PATH (e.g. file:C:/keys/securepay.p12) and KEYSTORE_PASSWORD; alias must be securepay
B's H2 consolehttp://localhost:8081/h2-console (JDBC jdbc:h2:file:./data/paymentdb, user sa, password password)Off, and its security chain isn't registered
mvn spring-boot:run "-Dspring-boot.run.profiles=dev"       # or  $env:SPRING_PROFILES_ACTIVE="dev"
.\scripts\start-all.ps1                                      # starts all three, sets dev for you

Start order matters: auth server → B → A.

9 · Testing

Postman collections and how to run them
Collection (in postman/)Use
Payment-ServiceA-AutomatedMain suite. Logs in as nithin, alice and a read-only nithin by script, then tests A: 401/403, create/get/list, ownership (404), duplicate (409), validation (400), malformed body, unknown route. Optional folder for 503 (stop B first)
Payment-Ownership-AutomatedSame login automation, ownership tests straight against B
Payment-Microservice-OAuth2, PaymentMicroServiceManual walkthrough: browser for login and consent, copy the code, exchange it

10 · Known limits

What this project does not do yet