Why this problem is easy to underestimate
When we start building microservices, the first security question usually sounds simple:
“Can we protect this API?”
The first answer is also usually simple. Put a gateway in front of the services. Add Keycloak or another identity provider. Validate the token at the gateway. Route the request to the downstream service.
That setup is useful, but it is not the complete answer.
The real question is:
“Can every service trust the request it has received, understand who or what is calling it, and decide whether that caller should be allowed to perform this action?”
That question changes the architecture.
I originally treated this topic like a setup tutorial: create Eureka, create a gateway, add Keycloak, create one public API and one protected API, then test with curl. It proved that authentication worked, but it did not explain the design decisions properly. This rewrite is closer to how I would discuss the same topic in a real implementation review.
The reference architecture
The basic building blocks are still familiar:
- Identity provider - Keycloak, Okta, Azure AD, Auth0, Cognito, or another OpenID Connect provider.
- API gateway - the public entry point for routing, coarse authorization, throttling, and request normalization.
- Discovery or service registry - useful for routing and resilience, but not a security boundary.
- Downstream services - accounts, payments, customer profile, limits, notifications, calculators, and other business services.
- Observability layer - audit logs, traces, correlation IDs, metrics, and alerts.
The important point is this:
The gateway is an enforcement point. It should not become the only enforcement point.
In a small internal system, gateway-only authorization may look acceptable for a while. In a banking, fintech, or enterprise environment, it becomes risky quickly. Services get reused. New channels appear. Batch jobs and schedulers start calling APIs. Partner integrations arrive. Someone exposes an internal route by mistake. If the downstream service blindly trusts the network, the blast radius becomes bigger than expected.
Gateway responsibility versus service responsibility
I like to split the responsibility this way:
| Layer | Good responsibility | Bad responsibility |
|---|---|---|
| Identity provider | Authenticate users and clients, issue tokens, publish signing keys, manage claims and scopes | Carrying business-specific authorization logic for every API |
| API gateway | Validate token shape, issuer, expiry, audience, route-level scopes, rate limits, and public/protected routes | Making every fine-grained business decision |
| Downstream service | Validate the token or trusted internal identity, enforce ownership, consent, limits, and business policy | Trusting headers just because they came from inside the network |
| Observability | Audit who called what, why the call was allowed or denied, and which policy was applied | Logging full tokens, secrets, or personal data |
This separation keeps the system understandable. The gateway can reject obvious bad requests early. The service still protects the business action.
For example:
- The gateway can decide that
/accounts/**requires an authenticated caller withaccounts.read. - The accounts service must still decide whether this user can read this specific account.
- The payments service must still decide whether a payment requires step-up authentication, maker-checker approval, velocity checks, or fraud review.
- The customer profile service must still decide which fields are visible for a role, purpose, and consent state.
The request flow
A normal external request looks like this:
GET /accounts/123456/transactions?from=2024-07-01
Authorization: Bearer <access-token>
X-Correlation-Id: 7e9c2f0d
The gateway should perform coarse checks:
- Is the token present?
- Is the token signature valid?
- Is the issuer trusted?
- Has the token expired?
- Is the token intended for this API audience?
- Does the token have the route-level scope?
- Is the caller within rate and abuse limits?
If these checks pass, the request can move forward. That does not mean the business action is automatically allowed.
The downstream service should then perform business checks:
- Does the caller have access to account
123456? - Is the caller acting as a customer, employee, partner, or system?
- Is the requested action allowed for this role?
- Is there a consent, purpose, region, or data classification rule?
- Does the service need step-up authentication?
- Should this action be audited?
This is the difference between authentication and authorization. Authentication tells us who or what is calling. Authorization decides whether the caller can perform the action.
Using Keycloak as the identity provider
Keycloak is a good local and self-hosted option for this kind of setup. In production, the same architecture applies even if the identity provider is different.
For a Spring Boot resource server, the cleanest configuration is usually to point the application to the issuer:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/bank
With this approach, Spring Security can use the provider metadata and JWKS endpoint to validate JWT signatures and token claims. The application should not hard-code public keys unless there is a strong reason. Let the platform handle key rotation.
For local Keycloak development, the issuer may look like this:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8082/realms/customers
That is fine for local development. Production should use HTTPS, proper realm/client configuration, managed secrets, and stricter token lifetimes.
Gateway security configuration
A simplified Spring Cloud Gateway security configuration can look like this:
package com.mk.gateway.configs;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/actuator/health").permitAll()
.pathMatchers(HttpMethod.POST, "/calculator/fd/interest/public").permitAll()
.pathMatchers("/accounts/**").hasAuthority("SCOPE_accounts.read")
.pathMatchers("/payments/**").hasAuthority("SCOPE_payments.write")
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
.build();
}
}
This is intentionally boring. Boring security configuration is usually better than clever security configuration.
The gateway should reject requests that are clearly not allowed. But I would not put all business rules here. The gateway does not know enough about account ownership, transaction state, consent, purpose, and product rules.
Service-level authorization
The accounts service should not only rely on the gateway. It should also run as a resource server and protect its own endpoints.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/bank
Then the service can enforce coarse method-level rules:
package com.mk.accounts.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/accounts")
public class AccountController {
@GetMapping("/{accountId}/transactions")
@PreAuthorize("hasAuthority('SCOPE_accounts.read')")
public TransactionSummary getTransactions(@PathVariable String accountId) {
return accountService.getTransactionsForAllowedCaller(accountId);
}
}
The important part is hidden inside getTransactionsForAllowedCaller. That is where the service checks whether the caller can read this particular account.
Scope checks are not enough for many real systems. A user may have accounts.read, but that does not mean the user can read every account. The service needs resource-level authorization.
Service-to-service calls
User-facing calls are not the only calls in a microservices system. Services also call each other.
For example:
- The payments service calls the limits service.
- The statement service calls the accounts service.
- The customer service calls the consent service.
- A scheduler calls the notification service.
For these cases, avoid passing usernames and passwords around. Also avoid using the OAuth resource owner password grant for modern applications. It exposes user credentials to the client and does not fit well with MFA or modern authentication flows.
For machine-to-machine calls, use one of these patterns:
- Client credentials - the calling service gets a token as itself.
- mTLS - the calling service proves its identity with a client certificate.
- Private key JWT - the service authenticates to the token endpoint using asymmetric keys.
- Workload identity - common in Kubernetes and cloud-native platforms.
A client credentials request looks like this:
POST /realms/bank/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
client_id=statement-service
client_secret=${STATEMENT_SERVICE_SECRET}
grant_type=client_credentials
scope=accounts.read
The service receiving this call should know that the caller is statement-service, not Mohit, not a random user, and not just “internal traffic”.
That distinction matters. A user token represents a user. A service token represents a service. Mixing them casually creates audit and authorization problems.
Propagating user context
Sometimes a downstream service needs to know the end user behind the request. There are multiple ways to do this:
- Forward the original access token.
- Exchange the external token for an internal token.
- Create a signed internal identity context at the edge.
- Use a service mesh or identity-aware proxy pattern.
Forwarding the original access token is simple, but it is not always the best design. It increases the number of services that need to understand external tokens and increases the impact if that token leaks.
For larger systems, I prefer creating an internal representation of the caller after the edge has validated the external token. That internal representation should be signed, short-lived, and never exposed back to the browser or external client.
The internal context may include:
{
"subject": "user-78231",
"actorType": "customer",
"channel": "mobile",
"roles": ["retail_customer"],
"scopes": ["accounts.read"],
"purpose": "self_service",
"correlationId": "7e9c2f0d"
}
Do not pass identity as plain headers such as X-User-Id unless every receiving service can cryptographically verify who created that header. Plain headers are too easy to spoof when a service is accidentally exposed or when an internal caller is compromised.
Public APIs are not unmanaged APIs
In the old version of this post, I used a fixed deposit calculator as the public API. That example is still useful.
A public endpoint may not need login:
POST /calculator/fd/interest/public
Content-Type: application/json
{
"schemeCode": "SCHEME_A",
"amount": 20000,
"durationInMonths": 12
}
But “public” does not mean “unprotected”.
Public APIs still need:
- Input validation
- Rate limiting
- Abuse detection
- Request size limits
- Bot controls where needed
- Logging and correlation IDs
- A clear decision on whether the result can be cached
Many incidents start with public endpoints that were considered harmless.
Token validation checklist
For every service that accepts a bearer token, I check at least the following:
| Check | Why it matters |
|---|---|
| Signature | Confirms the token was issued by the trusted issuer |
| Issuer | Prevents accepting tokens from the wrong realm or environment |
| Expiry and not-before | Rejects stale or not-yet-valid tokens |
| Audience | Prevents a token meant for one API from being replayed against another API |
| Scope or permission | Confirms route-level authorization |
| Subject or client ID | Identifies whether the caller is a user or a service |
| Token type | Avoids accidentally accepting ID tokens where access tokens are expected |
| Correlation ID | Supports audit and troubleshooting |
The audience check is worth calling out. I have seen systems validate signature and expiry but ignore audience. That means a token issued for one API may work against another API. In a microservices environment, that is a serious design gap.
What I would avoid
These are the patterns I would avoid in a production setup:
- Validating tokens only at the gateway and leaving sensitive services open internally.
- Passing
X-User-IdorX-Roleheaders without signing or trusted infrastructure controls. - Logging full access tokens.
- Storing client secrets in
application.yml. - Using the resource owner password grant for user login.
- Treating service discovery as a security mechanism.
- Giving one broad scope such as
api.accessto every client. - Using long-lived access tokens because refresh logic is inconvenient.
- Mixing user tokens and service tokens without an audit model.
- Relying on network location as the proof of identity.
Each of these choices may look convenient during development. Most of them become expensive later.
A practical delivery sequence
If I had to implement this in phases, I would do it like this:
- Set up the identity provider and define realms, clients, scopes, roles, and token lifetimes.
- Configure the gateway as a resource server and reject unauthenticated traffic by default.
- Mark public routes explicitly. Everything else should require authentication.
- Add route-level scope checks at the gateway.
- Configure sensitive downstream services as resource servers too.
- Add resource-level authorization inside the business services.
- Add service-to-service authentication using client credentials, mTLS, or workload identity.
- Add correlation IDs, audit events, and authorization decision logs.
- Review token contents and remove claims that do not need to travel.
- Run negative tests: wrong issuer, wrong audience, expired token, missing scope, spoofed header, direct service access.
The negative tests are important. Security is not proven by one happy curl command. It is proven when the wrong requests fail for the right reasons.
Final thought
The gateway is a good place to start, but it is not where the security design ends.
In a microservices system, every service is a small boundary. The service should understand the caller, validate the trust material it receives, and enforce the rules that belong to its own domain.
That is the difference between “we added authentication” and “the system is designed to survive real usage”.
References I keep handy
- OAuth 2.0 Security Best Current Practice, RFC 9700
- OWASP Microservices Security Cheat Sheet
- Spring Security OAuth2 Resource Server JWT documentation
- Keycloak OpenID Connect endpoints documentation
Want to apply these ideas in your organization?
I help fintech and banking teams turn architecture insights into practical execution plans.
