Secure Payment Microservices
Build a 5-service payment system secured with OAuth 2.0, mTLS, and HashiCorp Vault.
Introduction
30 Second Summary
Every time you tap your card or send money online, dozens of services verify your identity, check for fraud, and route your payment securely. Each service needs to trust the others without exposing sensitive credentials.
In this project, you will build a local payment platform with five Spring Boot microservices that demonstrate three production security patterns: JWT token relay for user identity, mTLS for service-to-service trust, and HashiCorp Vault for secret management.
What You'll Build
You authenticate as a merchant through Keycloak, trigger a payment, and watch it flow through an API Gateway to downstream services that each verify trust in a different way.
By the end of this project, you'll have:
- A running API Gateway that validates your JWT and relays it to backend services, so you can call one URL and reach any service securely.
- A Merchant Service that extracts your custom merchant_id claim from the token and returns data scoped to you.
- An end-to-end payment flow spanning Transaction and Notification services, where you can see token propagation in action and understand what breaks when it is missing.
- A Fraud Detection Service secured purely by mTLS certificates, proving service identity without any user token involved.
- Secret Mission: Wire HashiCorp Vault into your services so secrets are fetched at startup instead of hardcoded in YAML.
Are there any prerequisites?
This project assumes intermediate Java and Spring Boot experience. You should already have JDK 21, VS Code, and Docker Desktop installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. You're creating a five-service payment platform where each service demonstrates a different security pattern used in real production systems.
Set Up the Project and Infrastructure
Before any service can authenticate or authorize a request, you need the foundation in place. That means the project structure, a running identity provider, and the certificates that services will use to prove their identity to each other.
In this step, you will scaffold a multi-module Maven project, start Keycloak in a Docker container, and generate the mTLS certificates your services will use later.
In this step, get ready to:
- Create the multi-module Maven project with a parent POM and five service modules.
- Create a Docker Compose file with Keycloak and verify it starts.
- Generate self-signed CA and service certificates for mTLS.
Create the multi-module Maven project
A multi-module Maven project lets you manage five microservices under a single parent POM. The parent defines shared dependency versions so every service stays consistent.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir payment-platform
cd payment-platform
- Create the five service module directories by running:
mkdir gateway-service merchant-service transaction-service notification-service fraud-detection-service
- Confirm the directories exist by running:
ls
You should see all five service folders listed in the output.
- Open VS Code in this folder by running:
code .
VS Code not opening from the terminal?
- If you see "command not found", open VS Code manually, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Shell Command: Install 'code' command in PATH, and select it.
- Then retry code . in the terminal.
Why won't VS Code open from my terminal?
- In VS Code, create a new file called pom.xml in the root of your payment-platform folder.
- Paste the project identity and modules section into pom.xml:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>gateway-service</module>
<module>merchant-service</module>
<module>transaction-service</module>
<module>notification-service</module>
<module>fraud-detection-service</module>
</modules>
What does this section do?
- <packaging>pom</packaging> tells Maven this is a parent project, not a buildable application.
- <modules> lists the five child service directories Maven will manage together.
- Directly below the </modules> tag, paste the properties and dependency management section:
<properties>
<java.version>21</java.version>
<spring-boot.version>3.4.13</spring-boot.version>
<spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
What does the dependency management do?
The <dependencyManagement> section imports both the Spring Boot 3.4.13 and Spring Cloud 2024.0.0 BOMs. Every child module inherits compatible versions of Spring libraries without declaring version numbers individually.
- Save pom.xml.
Create the Docker Compose file for Keycloak
Keycloak is an open-source identity provider that issues OAuth 2.0 tokens. You will run it locally using Docker Compose in development mode.
- Create a new file called docker-compose.yml in the root of your payment-platform folder.
- Paste the following content into docker-compose.yml:
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- '8080:8080'
What does this compose file do?
- image pulls the official Keycloak 26.7.0 container image.
- start-dev runs Keycloak in development mode with an embedded H2 database and HTTP enabled.
- KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD create an initial admin user on first startup.
- Port 8080 maps the container's Keycloak console to your local machine.
- Save docker-compose.yml.
- Start Keycloak by running this command in your terminal (make sure you are in the payment-platform folder):
docker compose up -d
Docker will pull the Keycloak image and start the container in the background. This may take a minute on the first run.
- Check the container is running by running:
docker compose ps
You should see the keycloak container listed with a status of Up.
- Open your browser and navigate to http://localhost:8080.
You should see the Keycloak admin console login page.
Don't see the login page?
- Keycloak can take 30-60 seconds to fully start. Wait a moment and refresh the page.
- Run docker compose logs keycloak to check for errors in the container output.
- Make sure Docker Desktop is running and no other service is using port 8080.
Why isn't Keycloak starting in my Docker container?
Generate mTLS certificates using keytool
Mutual TLS means both sides of a connection present certificates to prove identity. You need a local Certificate Authority (CA) to sign the service certificates so they trust each other.
- Create a certs directory inside your payment-platform folder and move into it by running:
mkdir certs
cd certs
You will now generate keystores and truststores using keytool, the certificate management utility bundled with your JDK.
- Generate the CA key pair by running:
keytool -genkeypair -alias ca -keyalg RSA -keysize 2048 -validity 3650 \
-storetype PKCS12 -keystore ca-keystore.p12 -storepass changeit \
-dname "CN=Payment Platform CA, O=NextWork, C=US" \
-ext bc:c -noprompt
What does this command do?
- -genkeypair creates an RSA 2048-bit key pair and wraps the public key in a self-signed certificate.
- -ext bc:c marks this certificate as a CA (Basic Constraints: CA=true), allowing it to sign other certificates.
- -storetype PKCS12 creates a cross-platform keystore format compatible with Spring Boot SSL Bundles.
- Export the CA certificate so you can import it into truststores by running:
keytool -exportcert -alias ca -keystore ca-keystore.p12 -storepass changeit \
-rfc -file ca.pem
- Verify the CA files were created by running:
ls
You should see ca-keystore.p12 and ca.pem in the output.
- Generate the Fraud Detection service keystore by running:
keytool -genkeypair -alias fraud-detection -keyalg RSA -keysize 2048 -validity 365 \
-storetype PKCS12 -keystore fraud-detection.p12 -storepass changeit \
-dname "CN=fraud-detection-service, O=NextWork, C=US" -noprompt
- Create a CSR for fraud-detection, sign it with the CA, and import it back by running these commands:
keytool -certreq -alias fraud-detection -keystore fraud-detection.p12 \
-storepass changeit -file fraud-detection.csr
keytool -gencert -alias ca -keystore ca-keystore.p12 -storepass changeit \
-infile fraud-detection.csr -outfile fraud-detection-signed.pem -rfc \
-validity 365
keytool -importcert -alias ca -file ca.pem -keystore fraud-detection.p12 \
-storepass changeit -noprompt
keytool -importcert -alias fraud-detection -file fraud-detection-signed.pem \
-keystore fraud-detection.p12 -storepass changeit -noprompt
What is happening here?
You generate a Certificate Signing Request (CSR), then the CA signs it with -gencert. You import the CA certificate first so keytool can build the trust chain, then import the signed certificate back under the same alias.
- Generate the Merchant Service client keystore by running:
keytool -genkeypair -alias merchant-client -keyalg RSA -keysize 2048 -validity 365 \
-storetype PKCS12 -keystore merchant-client.p12 -storepass changeit \
-dname "CN=merchant-service, O=NextWork, C=US" -noprompt
- Create a CSR, sign it with the CA, and import the chain by running:
keytool -certreq -alias merchant-client -keystore merchant-client.p12 \
-storepass changeit -file merchant-client.csr
keytool -gencert -alias ca -keystore ca-keystore.p12 -storepass changeit \
-infile merchant-client.csr -outfile merchant-client-signed.pem -rfc \
-validity 365
keytool -importcert -alias ca -file ca.pem -keystore merchant-client.p12 \
-storepass changeit -noprompt
keytool -importcert -alias merchant-client -file merchant-client-signed.pem \
-keystore merchant-client.p12 -storepass changeit -noprompt
- Create the truststores by importing the CA certificate into each one:
keytool -importcert -alias ca -file ca.pem -storetype PKCS12 \
-keystore fraud-detection-truststore.p12 -storepass changeit -noprompt
keytool -importcert -alias ca -file ca.pem -storetype PKCS12 \
-keystore merchant-client-truststore.p12 -storepass changeit -noprompt
Why two truststores?
The Fraud Detection service uses fraud-detection-truststore.p12 to verify that incoming client certificates were signed by your CA.
The Merchant Service uses merchant-client-truststore.p12 to verify the Fraud Detection server certificate is legitimate. Both truststores contain the same CA certificate because the same CA signed both service certs.
- Verify all the keystores were created by running:
ls *.p12
You should see five files: ca-keystore.p12, fraud-detection.p12, merchant-client.p12, fraud-detection-truststore.p12, and merchant-client-truststore.p12.
- Create a README.md file in the certs folder to document what each file is. Paste the following content:
CA keystore: ca-keystore.p12
Fraud Detection keystore: fraud-detection.p12
Merchant Service client keystore: merchant-client.p12
Truststores: fraud-detection-truststore.p12, merchant-client-truststore.p12
- Save README.md.
- Navigate back to the project root by running:
cd ..
Seeing a keytool error?
- If you see a "command not found" error, verify JDK 21 is installed by running java -version. The keytool binary ships with the JDK.
- If you see "Failed to establish chain from reply", make sure you imported the CA certificate (ca.pem) before importing the signed certificate.
Why is keytool failing when I try to import my signed certificate?
✔️ Awesome, I've got everything!
Great. Double check you have saved pom.xml, docker-compose.yml, and certs/README.md.
ⓧ I'd like to double check the full code
Your pom.xml should contain both chunks you pasted above in order: the project identity and modules section (opening <project> through </modules>), then the properties and dependency management section (<properties> through closing </project>). Scroll up and confirm both are present.
Here is the complete docker-compose.yml:
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- '8080:8080'
Here is the complete certs/README.md:
CA keystore: ca-keystore.p12
Fraud Detection keystore: fraud-detection.p12
Merchant Service client keystore: merchant-client.p12
Truststores: fraud-detection-truststore.p12, merchant-client-truststore.p12
Your infrastructure is in place. Keycloak is running, your project is scaffolded, and your mTLS certificates are ready. Next up, you will configure Keycloak with a realm, client, and custom claims so your services know who is calling them.
Configure Keycloak and Build Merchant Service
Your Keycloak instance is running, but it has no idea who your merchants are. Without an identity provider issuing tokens that carry a merchant_id claim, downstream services have no way to identify who is calling them.
In this step you will configure Keycloak to issue JWTs with a custom merchant_id claim, then build a Merchant Service that validates those tokens and extracts merchant identity. By the end you will decode a real JWT and see your custom claim inside it.
In this step, get ready to:
- Configure a Keycloak realm, client, user, and custom claim mapper.
- Build the Merchant Service as a Spring Boot OAuth2 Resource Server.
- Test end-to-end by obtaining a token and calling the protected endpoint.
Configure the Keycloak realm and client
A realm in Keycloak is an isolated tenant. You will create a dedicated realm for your payment platform, then register a confidential client that can exchange user credentials for tokens.
- Open the Keycloak Admin Console at http://localhost:8080 in your browser.
- Log in with username admin and password admin.
- Click Manage realms in the left column.
- Click Create realm.
- Enter payment-realm in the Realm name field.
- Click Create.
You should now see payment-realm displayed next to Current realm at the top of the sidebar.
- Click Clients in the left menu.
- Click Create client.
- Set Client type to OpenID Connect.
- Enter payment-api in the Client ID field.
- Click Next.
- Toggle Client authentication to On.
- Confirm that Direct access grants is checked (this enables the Resource Owner Password Credentials flow for testing).
- Click Save.
Why enable Direct access grants?
In production you would use the Authorization Code flow with a browser redirect. For this project you use the Resource Owner Password Credentials grant so you can obtain a token directly from curl without needing a browser-based login page.
- Click the Credentials tab at the top of the client page.
- Copy the generated Client secret value. You will use this secret in your curl commands and Spring configuration. For this project the secret is payment-secret (you can regenerate it and use any value, but the code samples assume payment-secret).
Set a known client secret
Keycloak auto-generates a long random secret. For easier testing you can delete the generated value, type payment-secret in its place, and click Save. This keeps your commands consistent with the code samples.
Create the merchant user with a custom attribute
Your services need to know which merchant is making requests. You will store this as a user attribute and map it into the JWT.
- Click Realm Settings in the left menu.
- Click the General tab.
- Set Unmanaged Attributes to Enabled and click Save. This allows custom attributes you set on users to flow into tokens.
- Click Users in the left-hand menu.
- Click Create new user.
- Enter merchant1 as the Username.
- Click Create.
- Click the Credentials tab at the top of the user page.
- Click Set password.
- Enter merchant1 as the password.
- Toggle Temporary to Off.
- Click Save, then confirm.
- Click the Attributes tab.
- Add a new attribute with key merchant_id and value MCH-001.
- Click Save.
Now you need a protocol mapper so that merchant_id appears inside the JWT payload.
- Click Clients in the left menu, then click payment-api.
- Switch to the Client scopes tab.
- Click the payment-api-dedicated link in the Assigned client scope list.
- Click Configure a new mapper.
- Select User Attribute.
- Fill in the form: set Name to merchant_id, User Attribute to merchant_id, and Token Claim Name to merchant_id.
- Ensure Add to ID token and Add to access token are both On.
- Click Save.
Before you move on, verify that the custom claim actually appears in a token. What do you expect to see when you decode the JWT?
- Obtain a token by running this command in your terminal:
curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "client_id=payment-api" \
-d "client_secret=payment-secret" \
-d "username=merchant1" \
-d "password=merchant1" \
-d "grant_type=password"
You should see a JSON response containing an access_token field.
- Copy the access_token value (the long Base64 string).
- Open https://jwt.io in your browser and paste the token into the Encoded field.
- In the decoded payload you should see "merchant_id": "MCH-001".
Don't see merchant_id in the token?
- Confirm the protocol mapper has Add to access token set to On.
- Confirm you set the merchant_id attribute on the merchant1 user (not on a different user or a group).
- Confirm Unmanaged Attributes is Enabled in Realm Settings > General.
- help me debug my missing merchant_id claim in the JWT token
Build the Merchant Service as an OAuth2 Resource Server
Now that Keycloak issues tokens with your merchant_id, you need a service that validates those tokens and extracts the claim. The Merchant Service will be a Spring Boot application acting as an OAuth 2.0 Resource Server.
- Create the Merchant Service pom.xml inside the merchant-service module by adding these dependencies:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>merchant-service</artifactId>
<dependencies>
<!-- Web starter for REST endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Resource Server for JWT validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
</project>
What do these dependencies do?
- spring-boot-starter-web provides the embedded Tomcat server and Spring MVC for handling HTTP requests.
- spring-boot-starter-oauth2-resource-server adds JWT decoding and validation. Spring Security will automatically reject requests without a valid Bearer token.
- Create the application configuration at merchant-service/src/main/resources/application.yml:
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
What does issuer-uri do?
Spring Boot fetches the OpenID Connect discovery document from http://localhost:8080/realms/payment-realm/.well-known/openid-configuration at startup. It uses this to find Keycloak's public keys and validate every incoming JWT signature automatically.
- Create the security configuration class at merchant-service/src/main/java/com/nextwork/merchant/SecurityConfig.java:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter())));
return http.build();
}
private JwtAuthenticationConverter keycloakJwtConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("realm_access.roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
What does this code do?
- authorizeHttpRequests requires every request to carry a valid JWT.
- oauth2ResourceServer tells Spring Security to validate incoming Bearer tokens as JWTs.
- keycloakJwtConverter() maps Keycloak's realm_access.roles claim to Spring Security granted authorities prefixed with ROLE_. This lets you use role-based access control later.
- Create the controller at merchant-service/src/main/java/com/nextwork/merchant/MerchantController.java:
@RestController
@RequestMapping("/api/merchants")
public class MerchantController {
@GetMapping("/me")
public Map<String, Object> getMe(@AuthenticationPrincipal Jwt jwt) {
// Extract the custom merchant_id claim from the JWT
String merchantId = jwt.getClaimAsString("merchant_id");
return Map.of(
"merchantId", merchantId,
"username", jwt.getSubject(),
"message", "Welcome, merchant " + merchantId
);
}
}
What does @AuthenticationPrincipal Jwt do?
Spring Security automatically decodes the Bearer token and injects the Jwt object as the authenticated principal. You access any claim directly with jwt.getClaimAsString("merchant_id"). No manual parsing required.
- Save all files in the merchant-service module.
✔️ Awesome, I've got everything!
Great. Double check you have saved pom.xml, application.yml, SecurityConfig.java, and MerchantController.java.
ⓧ I'd like to double check the full code
Scroll back up and confirm each chunk is present in order:
- merchant-service/pom.xml with the parent reference and two dependencies.
- merchant-service/src/main/resources/application.yml with port 8081 and issuer-uri.
- SecurityConfig.java with the filterChain and keycloakJwtConverter methods.
- MerchantController.java with the /api/merchants/me endpoint extracting merchant_id.
Test the end-to-end flow
Time to prove everything works together. You will start the Merchant Service, obtain a token, and call the protected endpoint.
- Start the Merchant Service by running this from the merchant-service directory:
./mvnw spring-boot:run
Wait until you see a line like Started MerchantServiceApplication in the output.
Service fails to start?
- Confirm your Keycloak container is still running (docker ps should show the Keycloak container).
- Confirm issuer-uri in application.yml is exactly http://localhost:8080/realms/payment-realm.
- help me debug my Merchant Service failing to start
Before you call the endpoint, predict: what will happen if you send a request without a Bearer token?
- Open a new terminal and obtain a fresh token by running:
TOKEN=$(curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "client_id=payment-api" \
-d "client_secret=payment-secret" \
-d "username=merchant1" \
-d "password=merchant1" \
-d "grant_type=password" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo $TOKEN
What does this command do?
It calls the Keycloak token endpoint using the Resource Owner Password Credentials grant, then extracts just the access_token field from the JSON response and stores it in the TOKEN shell variable.
- Call the Merchant Service with the token by running:
curl -s http://localhost:8081/api/merchants/me \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
You should see a JSON response containing your merchant data:
{
"merchantId": "MCH-001",
"username": "merchant1",
"message": "Welcome, merchant MCH-001"
}
Getting a 401 Unauthorized?
- Run echo $TOKEN and confirm it is not empty. If empty, re-check the curl command and your client secret.
- Confirm the Merchant Service is running on port 8081.
- Tokens expire after 5 minutes by default. If too much time has passed, re-run the token command.
- help me debug my 401 error when calling the Merchant Service
Your identity layer is in place. Keycloak issues JWTs with a merchant_id claim, and the Merchant Service validates them and returns merchant-specific data. Next up, you will put an API Gateway in front of everything so clients never call backend services directly.
Build the API Gateway with Token Relay
Your Merchant Service validates JWTs and returns merchant-specific data. In a real payment platform, external clients never call backend services directly.
An API Gateway sits in front of all your services as the single entry point. It validates the token once and relays it downstream, so backend services never need to handle their own external authentication.
In this step, get ready to:
- Create the gateway service module with Spring Cloud Gateway dependencies.
- Configure routes with the TokenRelay filter to propagate JWT tokens downstream.
- Test the gateway by calling Merchant Service through it with your Bearer token.
Create the gateway service module
The gateway needs three dependencies: one for routing, one for JWT validation, and one to enable the TokenRelay filter that forwards the Authorization header to downstream services.
- Create the gateway service directory structure by running these commands from your project root:
mkdir -p gateway-service/src/main/java/com/nextwork/gateway
mkdir -p gateway-service/src/main/resources
- Confirm the directories were created by running:
ls gateway-service/src/main/resources
You should see the empty resources directory listed without errors.
- Create a new file at gateway-service/pom.xml and paste the following code:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>gateway-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
</dependencies>
</project>
What does this pom.xml do?
- spring-cloud-starter-gateway provides the reactive gateway with route matching and filter chains.
- spring-boot-starter-oauth2-resource-server auto-configures JWT validation using the issuer-uri you set in application properties.
- spring-cloud-starter-security enables the TokenRelay filter that copies the Authorization header onto downstream requests.
- Save gateway-service/pom.xml.
Maven can't resolve the dependencies?
- Make sure the parent pom.xml in your project root lists gateway-service in its <modules> block. It was added in Step 1.
- Check that spring-cloud.version is set to 2024.0.0 in the parent POM's properties section.
Why can't Maven resolve my gateway dependencies?
- Create the main application class at gateway-service/src/main/java/com/nextwork/gateway/GatewayApplication.java and paste the following code:
package com.nextwork.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Entry point for the API Gateway service
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
What does this class do?
This is the standard Spring Boot entry point. The @SpringBootApplication annotation triggers auto-configuration, which sets up reactive JWT validation and the gateway filter chain based on your application.yml. No explicit security configuration class is needed.
- Save GatewayApplication.java.
- Verify the file exists by running:
ls gateway-service/src/main/java/com/nextwork/gateway/
You should see GatewayApplication.java listed.
Configure routes and JWT validation
The gateway's application.yml defines which URL paths route to which services. It also tells Spring Security to validate JWTs against Keycloak before allowing any request through.
- Create a new file at gateway-service/src/main/resources/application.yml and paste the following configuration:
server:
port: 9000
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
cloud:
gateway:
routes:
- id: merchant-service
uri: http://localhost:8081
predicates:
- Path=/api/merchants/**
filters:
- TokenRelay=
- id: transaction-service
uri: http://localhost:8082
predicates:
- Path=/api/transactions/**
filters:
- TokenRelay=
What does this configuration do?
- server.port: 9000 makes the gateway listen on port 9000, separate from Keycloak (8080) and Merchant Service (8081).
- The issuer-uri tells Spring Security to fetch Keycloak's public key and validate incoming JWTs. Requests without a valid token are rejected before reaching any backend.
- Each route matches a path pattern (Path=/api/merchants/**) and forwards to the target service URI.
- TokenRelay= copies the incoming Authorization header onto the downstream request, so backend services receive the same Bearer token the client sent.
Why a gateway instead of direct calls?
Without a gateway, every backend service must be exposed to external traffic and validate tokens independently. The gateway creates a single security boundary. It validates once, then relays trust downstream.
This also gives you one place to add rate limiting, logging, and circuit breakers later.
- Save application.yml.
- Make sure your Merchant Service is still running on port 8081. Start the gateway service by running:
cd gateway-service
../mvnw spring-boot:run
You should see the gateway start up on port 9000 with log output indicating it connected to Keycloak's issuer URI.
Gateway fails to start?
- If you see an error about connecting to localhost:8080, make sure your Keycloak container is running. Check with docker ps.
- If you see a port conflict on 9000, make sure no other process is using that port.
- If Maven cannot resolve dependencies, check that your parent pom.xml includes the spring-cloud.version property set to 2024.0.0.
Why won't my gateway service start?
Test the token relay through the gateway
Now you will call the Merchant Service through the gateway and verify the response matches what you got when calling the service directly in the previous step.
- Open a new terminal tab. Obtain a fresh access token from Keycloak by running:
curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=payment-api" \
-d "client_secret=payment-secret" \
-d "username=merchant1" \
-d "password=merchant1"
- Copy the access_token value from the JSON response.
Before you run the next command, predict what you expect to see. The gateway validates your token and routes to Merchant Service. Do you think you will get the same merchant data as calling port 8081 directly?
- Call the Merchant Service through the gateway on port 9000 by running:
curl -H "Authorization: Bearer your-access-token-here" http://localhost:9000/api/merchants/me
You should see the same JSON response you received when calling Merchant Service directly on port 8081:
{"merchantId":"MCH-001","username":"merchant1","message":"Welcome, merchant MCH-001"}
What just happened?
Your request hit port 9000. The gateway validated the JWT against Keycloak's public key. Because the token was valid, the gateway matched the path /api/merchants/** and forwarded your request to http://localhost:8081 with the Authorization header intact.
Merchant Service received the same Bearer token, extracted merchant_id from it, and returned your merchant data. The client never needed to know the backend service exists.
Getting a 401 Unauthorized?
- Tokens expire quickly. Obtain a fresh one and try again.
- Make sure you replaced your-access-token-here with the actual token value. Use only the long Base64 string, not the entire JSON response.
- Confirm Keycloak is reachable on port 8080 and the realm name in your gateway config is payment-realm.
Why am I getting 401 when calling through the gateway?
✔️ Awesome, I've got everything!
Great. Double check you have saved both gateway-service/pom.xml and gateway-service/src/main/resources/application.yml.
ⓧ I'd like to double check the full code
Here is the complete gateway-service/pom.xml:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>gateway-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
</dependencies>
</project>
Here is the complete gateway-service/src/main/resources/application.yml:
server:
port: 9000
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
cloud:
gateway:
routes:
- id: merchant-service
uri: http://localhost:8081
predicates:
- Path=/api/merchants/**
filters:
- TokenRelay=
- id: transaction-service
uri: http://localhost:8082
predicates:
- Path=/api/transactions/**
filters:
- TokenRelay=
Your API Gateway is live and relaying tokens downstream. Next up, you will build the Transaction and Notification services to see what happens when one service needs to call another with the user's token.
Build Transaction and Notification Services
Real payment flows span multiple services. Transaction Service processes the payment, then calls Notification Service to alert the merchant.
The challenge is that each service validates its own JWT independently. If Transaction Service calls Notification Service without forwarding the merchant's token, Notification has no idea who is making the request and rejects it. You will experience this failure firsthand and then fix it with WebClient token propagation.
In this step, get ready to:
- Build Notification Service as an OAuth2 Resource Server that validates relayed JWTs.
- Build Transaction Service and experience the 401 failure when tokens are not propagated.
- Fix the failure with ServletBearerExchangeFilterFunction and verify the full payment chain.
Build the Notification Service
Notification Service is a lightweight OAuth2 Resource Server. It receives a relayed JWT from upstream services, extracts the merchant_id claim, and returns a confirmation. You need this service running before you can demonstrate what happens when a token is not propagated.
- Create the file notification-service/pom.xml with the following dependencies:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>notification-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
</project>
What does this pom.xml do?
This declares two dependencies. spring-boot-starter-web gives you the embedded Tomcat server and REST controller support. spring-boot-starter-oauth2-resource-server auto-configures JWT validation so every endpoint requires a valid Bearer token.
- Create the application config at notification-service/src/main/resources/application.yml:
server:
port: 8083
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
- Create the main application class at notification-service/src/main/java/com/nextwork/notification/NotificationApplication.java:
package com.nextwork.notification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class NotificationApplication {
public static void main(String[] args) {
SpringApplication.run(NotificationApplication.class, args);
}
}
What does this configuration do?
The issuer-uri tells Spring Security where to fetch the OpenID Connect discovery document. Spring Boot auto-configures all endpoints to require a valid JWT issued by your Keycloak realm. No separate SecurityConfig class is needed for basic authentication enforcement.
- Create the controller at notification-service/src/main/java/com/nextwork/notification/NotificationController.java:
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
@PostMapping
public Map<String, Object> sendNotification(
@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, Object> payload) {
String merchantId = jwt.getClaimAsString("merchant_id");
return Map.of(
"notificationId", UUID.randomUUID().toString(),
"merchantId", merchantId,
"status", "SENT",
"message", "Payment notification sent to merchant " + merchantId
);
}
}
What does this controller do?
The @AuthenticationPrincipal Jwt jwt parameter extracts the validated JWT from the security context. The controller reads merchant_id from the token claims (the same custom claim you configured in Keycloak) and returns a notification confirmation scoped to that merchant.
This endpoint only works when the caller provides a valid Bearer token. That is the key detail you will test shortly.
- Start Notification Service by running this command from the notification-service directory:
./mvnw spring-boot:run
- Confirm the service starts on port 8083 by checking for Started NotificationApplication in the console output.
Service fails to start?
- Make sure Keycloak is still running on port 8080. The issuer-uri must be reachable at startup for Spring to fetch the OIDC discovery document.
- Check that your package structure matches com.nextwork.notification and the @SpringBootApplication class is at the package root.
Help me debug why my Notification Service won't start.
Build Transaction Service without token propagation
Transaction Service processes payments and calls Notification Service to confirm delivery. You will intentionally build it without forwarding the merchant's token to see what happens when a downstream service receives an unauthenticated request.
- Create transaction-service/pom.xml with the following dependencies:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>transaction-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
</dependencies>
</project>
Why does Transaction Service need webflux and oauth2-client?
The spring-boot-starter-webflux dependency provides WebClient, the non-blocking HTTP client you use to call Notification Service. The spring-security-oauth2-client dependency provides ServletBearerExchangeFilterFunction, which you will add later to propagate tokens.
- Create the application config at transaction-service/src/main/resources/application.yml:
server:
port: 8082
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
notification:
service:
url: http://localhost:8083
- Create the main application class at transaction-service/src/main/java/com/nextwork/transaction/TransactionApplication.java:
package com.nextwork.transaction;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TransactionApplication {
public static void main(String[] args) {
SpringApplication.run(TransactionApplication.class, args);
}
}
- Create a WebClient configuration at transaction-service/src/main/java/com/nextwork/transaction/WebClientConfig.java WITHOUT any token propagation filter:
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
// No token filter - calls to other services will be unauthenticated
return WebClient.builder().build();
}
}
- Create the controller at transaction-service/src/main/java/com/nextwork/transaction/TransactionController.java:
@RestController
@RequestMapping("/api/transactions")
public class TransactionController {
private final WebClient webClient;
public TransactionController(WebClient webClient) {
this.webClient = webClient;
}
@PostMapping
public Map<String, Object> createTransaction(
@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, Object> payload) {
String merchantId = jwt.getClaimAsString("merchant_id");
// Call Notification Service with token relay
Map notification = webClient.post()
.uri("http://localhost:8083/api/notifications")
.bodyValue(Map.of("merchantId", merchantId, "payload", payload))
.retrieve()
.bodyToMono(Map.class)
.block();
return Map.of(
"transactionId", UUID.randomUUID().toString(),
"merchantId", merchantId,
"status", "PROCESSED",
"notification", notification
);
}
}
What does TransactionController do?
The controller extracts merchant_id from the caller's JWT, then uses WebClient to call Notification Service at http://localhost:8083/api/notifications. The .block() call waits synchronously for the response.
The current WebClientConfig creates a plain WebClient with no filter. This means the outgoing request to Notification Service will NOT carry the merchant's Bearer token. Notification will reject it.
- Start Transaction Service from the transaction-service directory:
./mvnw spring-boot:run
- Confirm you see Started TransactionApplication on port 8082.
Before you run the next command, what do you think will happen when Transaction Service tries to call Notification Service without forwarding the token?
- First, obtain a fresh access token (same as previous steps):
curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=payment-api" \
-d "client_secret=your-client-secret-here" \
-d "username=merchant1" \
-d "password=password" | jq -r '.access_token'
- Store the token in a variable, then call the transaction endpoint through the Gateway:
TOKEN=$(curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=payment-api" \
-d "client_secret=your-client-secret-here" \
-d "username=merchant1" \
-d "password=password" | jq -r '.access_token')
curl -s -X POST http://localhost:9000/api/transactions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"amount": 99.99, "currency": "USD"}' | jq .
You will see a 500 Internal Server Error response. Check the Transaction Service console logs and you will find a 401 Unauthorized response from Notification Service. That is the designed obstacle.
Why did Notification reject the call?
The Gateway validated your token and relayed it to Transaction Service. Transaction Service received the token and extracted your merchant_id claim successfully.
But when Transaction Service called Notification Service via WebClient, it used a plain HTTP client with no Authorization header. Notification Service saw an anonymous request and returned 401. The token stopped at Transaction Service instead of flowing through to the next hop.
Fix the failure with token relay
Spring Security provides ServletBearerExchangeFilterFunction, a WebClient filter that automatically extracts the Bearer token from the current security context and attaches it to every outgoing request.
- In WebClientConfig.java, find this line:
return WebClient.builder().build();
- Replace it with the following, which adds the token propagation filter:
return WebClient.builder()
.filter(new ServletBearerExchangeFilterFunction())
.build();
Your complete WebClientConfig.java should now look like this:
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.filter(new ServletBearerExchangeFilterFunction())
.build();
}
}
What does ServletBearerExchangeFilterFunction do?
This filter intercepts every outgoing WebClient request. It looks up the current Authentication in the security context, extracts the OAuth 2.0 Bearer token, and adds it as an Authorization: Bearer <token> header on the outgoing call.
This means your merchant's JWT automatically flows from the Gateway to Transaction Service to Notification Service. Each service in the chain receives the same user-context token without any manual header copying.
- Save WebClientConfig.java and restart Transaction Service.
- Run the same curl command again to call the transaction endpoint:
curl -s -X POST http://localhost:9000/api/transactions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"amount": 99.99, "currency": "USD"}' | jq .
This time you will see a successful JSON response containing both the transaction details and the notification confirmation from Notification Service.
{
"transactionId": "some-uuid",
"merchantId": "MCH-001",
"status": "PROCESSED",
"notification": {
"notificationId": "some-uuid",
"merchantId": "MCH-001",
"status": "SENT",
"message": "Payment notification sent to merchant MCH-001"
}
}
Still getting a 500 error?
- Make sure you restarted Transaction Service after editing WebClientConfig.java. The bean definition is read at startup.
- Verify that Notification Service is still running on port 8083. If it crashed, restart it.
- If your token expired, obtain a fresh one with the curl token request above and set the TOKEN variable again.
Help me debug why my Transaction Service still fails after adding the filter.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in both transaction-service and notification-service.
ⓧ I'd like to double check the full code
Confirm you have the following files. Scroll up and verify each code block matches what you have saved:
- notification-service/pom.xml with spring-boot-starter-web and spring-boot-starter-oauth2-resource-server dependencies.
- notification-service/src/main/resources/application.yml with port 8083 and the issuer-uri pointing to your Keycloak realm.
- notification-service/src/main/java/com/nextwork/notification/NotificationController.java with the @PostMapping endpoint extracting merchant_id from the JWT.
- transaction-service/pom.xml with spring-boot-starter-webflux and spring-security-oauth2-client in addition to the web and resource server starters.
- transaction-service/src/main/resources/application.yml with port 8082, issuer-uri, and the notification.service.url property.
- transaction-service/src/main/java/com/nextwork/transaction/WebClientConfig.java with ServletBearerExchangeFilterFunction as the WebClient filter.
- transaction-service/src/main/java/com/nextwork/transaction/TransactionController.java with the @PostMapping that calls Notification via WebClient.
You just proved why token propagation matters in microservices. Without it, downstream services have no idea who the original caller is. Next up, you will secure an internal service with mTLS certificates where no user identity should flow at all.
Secure Fraud Detection with mTLS
Your payment flow now routes user identity through JWTs from Gateway to Transaction to Notification. But fraud detection is different.
Fraud checks are an internal operation. No end-user identity should flow into them because the trust relationship is between services, not between a user and a service. This is where mTLS (mutual TLS) fits. The calling service proves its identity with a certificate, and the receiving service verifies that certificate against a trusted CA. No JWT involved at all.
In this step, get ready to:
- Configure Fraud Detection Service with mTLS enforcement so only certificate-holding services can reach it.
- Wire Merchant Service with an SSL Bundle and a dedicated mTLS-enabled RestClient.
- Test the difference: successful call with a certificate, TLS handshake failure without one.
Configure Fraud Detection Service with mTLS
Fraud Detection Service deliberately has no OAuth2 dependency. It trusts callers based on their TLS certificate alone.
- Copy the certificate files into the Fraud Detection Service classpath by running these commands:
mkdir -p fraud-detection-service/src/main/resources/certs
cp certs/fraud-detection.p12 fraud-detection-service/src/main/resources/certs/
cp certs/fraud-detection-truststore.p12 fraud-detection-service/src/main/resources/certs/
Why copy certs into resources?
Spring Boot resolves classpath: paths from the src/main/resources directory. Placing the keystores there means the embedded server can load them at startup without absolute file paths.
- Create the Fraud Detection Service POM by adding this file at fraud-detection-service/pom.xml:
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>fraud-detection-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
What does this POM do?
Notice there is no spring-boot-starter-oauth2-resource-server here. Fraud Detection Service has zero JWT awareness. It relies purely on the TLS layer for authentication.
- Confirm the file was created by running:
ls fraud-detection-service/pom.xml
You should see fraud-detection-service/pom.xml listed in the output.
- Create the Fraud Detection controller at fraud-detection-service/src/main/java/com/nextwork/fraud/FraudController.java:
@RestController
@RequestMapping("/api/fraud")
public class FraudController {
@GetMapping("/check")
public Map<String, Object> checkFraud(@RequestParam String merchantId) {
// Return a fraud score for the given merchant
return Map.of(
"merchantId", merchantId,
"fraudScore", 0.02,
"status", "CLEAR",
"message", "No fraud detected for merchant " + merchantId
);
}
}
What does this controller do?
The /api/fraud/check endpoint accepts a merchantId query parameter and returns a hardcoded fraud score. In production this would call a fraud model. The key point is that it never inspects a JWT. Access control happens entirely at the TLS handshake level.
- Create the application configuration at fraud-detection-service/src/main/resources/application.yml:
server:
port: 8084
ssl:
enabled: true
key-store: classpath:certs/fraud-detection.p12
key-store-password: changeit
key-store-type: PKCS12
trust-store: classpath:certs/fraud-detection-truststore.p12
trust-store-password: changeit
trust-store-type: PKCS12
client-auth: need
What does client-auth: need do?
The server.ssl.client-auth: need property tells the embedded Tomcat server to reject any TLS connection that does not present a valid client certificate. The truststore contains the CA certificate that signed the Merchant Service's client certificate, so only services holding that CA-signed cert can connect.
- Save all three files.
- Start Fraud Detection Service by running:
cd fraud-detection-service
./mvnw spring-boot:run
You should see a log line containing Tomcat started on port 8084 (https) confirming the service is running with SSL enabled.
Service fails to start?
Check that fraud-detection.p12 exists at fraud-detection-service/src/main/resources/certs/. Missing keystore files cause a FileNotFoundException at startup.
Also verify the password in your YAML matches the password you used when generating the keystore in Step 1.
help me debug my Fraud Detection Service SSL startup failure
Configure Merchant Service with an mTLS-enabled RestClient
Merchant Service needs to present its client certificate when calling Fraud Detection. Spring Boot SSL Bundles let you declare named keystore/truststore pairs in YAML, then inject them into a RestClient programmatically.
- Copy the Merchant Service client certificates into its classpath by running:
mkdir -p merchant-service/src/main/resources/certs
cp certs/merchant-client.p12 merchant-service/src/main/resources/certs/
cp certs/merchant-client-truststore.p12 merchant-service/src/main/resources/certs/
- Confirm the files are in place by running:
ls merchant-service/src/main/resources/certs/
You should see merchant-client.p12 and merchant-client-truststore.p12 in the output.
- Update merchant-service/src/main/resources/application.yml to declare the SSL bundle. Replace the entire file with:
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
ssl:
bundle:
jks:
fraud-client:
keystore:
location: classpath:certs/merchant-client.p12
password: changeit
type: PKCS12
truststore:
location: classpath:certs/merchant-client-truststore.p12
password: changeit
type: PKCS12
What does the SSL bundle configure?
The spring.ssl.bundle.jks.fraud-client section declares a named bundle called fraud-client. The keystore contains Merchant Service's client certificate (what it presents to Fraud Detection). The truststore contains the CA that signed Fraud Detection's server certificate (what Merchant Service trusts).
- Create the mTLS client configuration at merchant-service/src/main/java/com/nextwork/merchant/MtlsClientConfig.java:
@Configuration
public class MtlsClientConfig {
@Bean
public RestClient fraudClient(SslBundles sslBundles) {
SslBundle bundle = sslBundles.getBundle("fraud-client");
SSLContext sslContext = bundle.createSslContext();
// Configure HttpClient with SSLContext
return RestClient.builder()
.baseUrl("https://localhost:8084")
.requestFactory(new HttpComponentsClientHttpRequestFactory(
HttpClients.custom().setSSLContext(sslContext).build()))
.build();
}
}
What does this configuration do?
Spring Boot auto-configures an SslBundles bean from your YAML. This config retrieves the fraud-client bundle, creates an SSLContext containing both the client key and the trusted CA, then builds an Apache HttpClient that presents the client certificate on every request.
The RestClient is pre-configured with a base URL of https://localhost:8084 (Fraud Detection's HTTPS endpoint). Every call through this client automatically performs the mTLS handshake.
- Save both files (application.yml and MtlsClientConfig.java).
Import errors in MtlsClientConfig?
Make sure your merchant-service/pom.xml includes the Apache HttpClient dependency (org.apache.httpcomponents.client5:httpclient5). Without it, HttpClients and HttpComponentsClientHttpRequestFactory will not resolve.
help me fix missing Apache HttpClient imports in my Spring Boot mTLS configuration
Add the fraud-check endpoint and test mTLS
Now you will connect the mTLS client to a new endpoint in MerchantController and prove that certificate-based authentication works.
- In merchant-service/src/main/java/com/nextwork/merchant/MerchantController.java, replace the entire file with:
@RestController
@RequestMapping("/api/merchants")
public class MerchantController {
private final RestClient fraudClient;
public MerchantController(RestClient fraudClient) {
this.fraudClient = fraudClient;
}
@GetMapping("/me")
public Map<String, Object> getMe(@AuthenticationPrincipal Jwt jwt) {
String merchantId = jwt.getClaimAsString("merchant_id");
return Map.of(
"merchantId", merchantId,
"username", jwt.getSubject(),
"message", "Welcome, merchant " + merchantId
);
}
@GetMapping("/fraud-check")
public Map<String, Object> fraudCheck(@AuthenticationPrincipal Jwt jwt) {
String merchantId = jwt.getClaimAsString("merchant_id");
// Call Fraud Detection via mTLS (no JWT)
Map result = fraudClient.get()
.uri("/api/fraud/check?merchantId=" + merchantId)
.retrieve()
.body(Map.class);
return Map.of("merchantId", merchantId, "fraudCheck", result);
}
}
What changed in MerchantController?
The controller now injects the fraudClient RestClient bean via constructor injection. The new /api/merchants/fraud-check endpoint extracts your merchant_id from the JWT (so it still needs an authenticated user calling through the Gateway) but passes only the merchant ID as a query parameter to Fraud Detection over mTLS. No Authorization header is forwarded.
- Save MerchantController.java.
- Restart Merchant Service by running:
cd merchant-service
./mvnw spring-boot:run
Confirm you see Tomcat started on port 8081 in the logs.
Before you test, think about what will happen. You are calling Fraud Detection through the mTLS-enabled RestClient. Do you expect the request to succeed or fail?
- Obtain a fresh access token from Keycloak by running:
curl -s -X POST http://localhost:8080/realms/payment-realm/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=payment-api" \
-d "client_secret=your-client-secret-here" \
-d "username=merchant1" \
-d "password=password" | jq -r '.access_token'
- Call the fraud-check endpoint through the Gateway using your token:
curl -H "Authorization: Bearer YOUR_TOKEN_HERE" \
http://localhost:9000/api/merchants/fraud-check
You should see a JSON response containing your merchantId and a nested fraudCheck object with "status": "CLEAR" and "fraudScore": 0.02. The mTLS handshake succeeded because Merchant Service presented its client certificate.
Why JWT and mTLS together?
The Gateway validates the JWT and relays it to Merchant Service, which extracts the merchant_id claim. But the call from Merchant Service to Fraud Detection uses only mTLS. The JWT carries user identity through the front door. The certificate carries service identity through the back door.
This is exactly how production payment systems work. User-facing flows use tokens. Internal service-mesh flows use certificates.
Now prove that mTLS actually blocks callers without a valid certificate. What do you think happens if you call Fraud Detection directly with curl (which has no client certificate)?
- Attempt a direct call to Fraud Detection without a certificate by running:
curl -k https://localhost:8084/api/fraud/check?merchantId=MCH-001
The request fails with a TLS handshake error. The -k flag skips server certificate verification, but the server still demands a client certificate. Without one, the connection is refused at the TLS layer before the request even reaches the controller.
Why mTLS instead of JWT for Fraud Detection?
JWT token relay carries user identity and works well for flows where the downstream service needs to know which user made the request. Fraud Detection does not care about the user. It cares that the calling service is authorized to request a fraud check.
Certificates prove service identity at the network layer. If a service does not have the right certificate, it cannot even establish a TCP connection. This makes mTLS stronger for internal service-to-service trust where user context is irrelevant.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in both fraud-detection-service and merchant-service.
ⓧ I'd like to double check the full code
Here are the key files for this step. Scroll up and confirm each one matches what you have.
fraud-detection-service/pom.xml
<project>
<parent>
<groupId>com.nextwork</groupId>
<artifactId>payment-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>fraud-detection-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
fraud-detection-service/src/main/resources/application.yml
server:
port: 8084
ssl:
enabled: true
key-store: classpath:certs/fraud-detection.p12
key-store-password: changeit
key-store-type: PKCS12
trust-store: classpath:certs/fraud-detection-truststore.p12
trust-store-password: changeit
trust-store-type: PKCS12
client-auth: need
fraud-detection-service/src/main/java/com/nextwork/fraud/FraudController.java
@RestController
@RequestMapping("/api/fraud")
public class FraudController {
@GetMapping("/check")
public Map<String, Object> checkFraud(@RequestParam String merchantId) {
return Map.of(
"merchantId", merchantId,
"fraudScore", 0.02,
"status", "CLEAR",
"message", "No fraud detected for merchant " + merchantId
);
}
}
merchant-service/src/main/resources/application.yml
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/payment-realm
ssl:
bundle:
jks:
fraud-client:
keystore:
location: classpath:certs/merchant-client.p12
password: changeit
type: PKCS12
truststore:
location: classpath:certs/merchant-client-truststore.p12
password: changeit
type: PKCS12
merchant-service/src/main/java/com/nextwork/merchant/MtlsClientConfig.java
@Configuration
public class MtlsClientConfig {
@Bean
public RestClient fraudClient(SslBundles sslBundles) {
SslBundle bundle = sslBundles.getBundle("fraud-client");
SSLContext sslContext = bundle.createSslContext();
// Configure HttpClient with SSLContext
return RestClient.builder()
.baseUrl("https://localhost:8084")
.requestFactory(new HttpComponentsClientHttpRequestFactory(
HttpClients.custom().setSSLContext(sslContext).build()))
.build();
}
}
merchant-service/src/main/java/com/nextwork/merchant/MerchantController.java
@RestController
@RequestMapping("/api/merchants")
public class MerchantController {
private final RestClient fraudClient;
public MerchantController(RestClient fraudClient) {
this.fraudClient = fraudClient;
}
@GetMapping("/me")
public Map<String, Object> getMe(@AuthenticationPrincipal Jwt jwt) {
String merchantId = jwt.getClaimAsString("merchant_id");
return Map.of(
"merchantId", merchantId,
"username", jwt.getSubject(),
"message", "Welcome, merchant " + merchantId
);
}
@GetMapping("/fraud-check")
public Map<String, Object> fraudCheck(@AuthenticationPrincipal Jwt jwt) {
String merchantId = jwt.getClaimAsString("merchant_id");
Map result = fraudClient.get()
.uri("/api/fraud/check?merchantId=" + merchantId)
.retrieve()
.body(Map.class);
return Map.of("merchantId", merchantId, "fraudCheck", result);
}
}
You have proven that mTLS locks down Fraud Detection to certificate-holding services only. Next up, you will take the final step toward production readiness by wiring HashiCorp Vault to manage all these passwords and secrets instead of hardcoding them in YAML files.
Secret mission
HashiCorp Vault for Secret Management
Right now your Keycloak client-secret and mTLS keystore passwords are hardcoded in application.yml. In production, secrets never live in config files. Wire HashiCorp Vault into your services so secrets are fetched at startup instead of sitting in plain text.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker containers, so there are no ongoing cloud costs.
Resources you used:
- Docker containers: Keycloak (port 8080) and HashiCorp Vault (port 8200).
- Local certificates in the certs/ directory.
- The payment-platform project directory with all five service modules.
- Keycloak realm configuration (payment-realm, client, user, mapper) stored inside the Keycloak container volume.
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue the project soon.
- Your Docker containers (Keycloak and Vault) will keep running until you stop them manually or restart your machine.
- Your project files, certificates, and Keycloak realm configuration all persist as-is.
- Docker Desktop may use some memory while containers are running.
Pause - I'll come back to this later
Shut down running containers to free up memory while keeping all your data and configuration intact.
- Stop all containers by running this command from your payment-platform directory:
docker compose stop
- Your container data (Keycloak realm, Vault secrets) is preserved in Docker volumes.
- When you're ready to resume, run docker compose start from the same directory to bring everything back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent and cannot be undone.
- Stop and remove all containers, networks, and volumes by running this from your payment-platform directory:
docker compose down -v
- This removes the Keycloak container (and your realm configuration) and the Vault container (and all stored secrets).
- Delete the entire project directory by running:
macOS
cd ~/Desktop
rm -rf payment-platform
Windows
cd ~\Desktop
rmdir /s /q payment-platform
- This removes all service source code, the certs/ directory with your generated certificates, and the docker-compose.yml file.
That's a Wrap!
That's a Wrap!
Nice work! You've built a production-grade payment microservices platform secured by three distinct security patterns, all running locally on your machine.
You've learned how to:
- Build a 5-service payment platform with an API Gateway that validates JWTs and relays them downstream, using @AuthenticationPrincipal Jwt to extract custom merchant_id claims from Keycloak-issued tokens.
- Implement JWT token relay across services using Spring Cloud Gateway's TokenRelay filter and WebClient's ServletBearerExchangeFilterFunction, diagnosing and fixing the 401 failure that occurs when tokens are not propagated.
- Secure internal service-to-service communication with mTLS certificates using Spring Boot SSL Bundles, understanding when to use JWT for user-context flows versus mTLS for service-identity trust.
- Secret Mission: Integrated HashiCorp Vault to manage secrets via Spring Cloud Vault's ConfigData API, removing hardcoded credentials from configuration files.
Ready to quiz yourself?