Tampilan
Kenapa penting
Di monolit, sesi login hidup di satu proses dan satu SecurityFilterChain menjaga semuanya. Di microservices, satu klik "Bayar" bisa melewati gateway, order-service, inventory-service, dan payment-service — empat proses, empat pintu masuk. Kalau hanya pintu depan yang dikunci, penyerang yang berhasil masuk ke satu pod (lewat library rentan, misalnya) bisa berjalan bebas ke service lain dan menagih kartu kredit siapa saja. Di tempat kerja kamu akan diminta menjawab pertanyaan seperti "kenapa service internal juga cek token?", "token siapa yang dipakai saat service memanggil service?", dan "bagaimana tahu siapa yang mengubah data ini?". Bab ini menjawabnya dengan pola yang dipakai tim Spring di production.
Konsep inti
Zero trust: gateway mengautentikasi, setiap service memvalidasi
Pola lama "perimeter security" menganggap semua yang ada di dalam jaringan internal sudah aman. Zero trust membalik asumsi itu: setiap request harus membuktikan identitasnya, di setiap hop. Pembagian tugasnya:
| Komponen | Tugas security |
|---|---|
| Authorization server (mis. Keycloak) | Login pengguna, menerbitkan JWT bertanda tangan, mengelola client & scope |
| API gateway | Menolak request tanpa token sedini mungkin, rate limit, meneruskan token |
| Setiap service | Memvalidasi JWT sendiri (signature, iss, aud, exp) dan mengotorisasi per endpoint |
Validasi di gateway itu optimasi — membuang sampah lebih awal. Validasi di service itu wajib. Logika yang harus dicek setiap service bisa kamu lihat dalam bentuk Java murni:
java
import java.time.Instant;
import java.util.List;
import java.util.Set;
public class ZeroTrustCheck {
record TokenClaims(String issuer, List<String> audience, Set<String> scopes, Instant expiresAt) {}
sealed interface Decision permits Allowed, Denied {}
record Allowed(String reason) implements Decision {}
record Denied(int status, String reason) implements Decision {}
static final String TRUSTED_ISSUER = "https://auth.toko.local/realms/toko";
static final String MY_AUDIENCE = "payment-service";
static Decision check(TokenClaims c, String requiredScope, Instant now) {
if (!TRUSTED_ISSUER.equals(c.issuer())) return new Denied(401, "issuer tidak dikenal");
if (now.isAfter(c.expiresAt())) return new Denied(401, "token kedaluwarsa");
if (!c.audience().contains(MY_AUDIENCE)) return new Denied(401, "token bukan untuk " + MY_AUDIENCE);
if (!c.scopes().contains(requiredScope)) return new Denied(403, "scope " + requiredScope + " tidak ada");
return new Allowed("ok");
}
public static void main(String[] args) {
Instant now = Instant.parse("2026-10-23T08:00:00Z");
Instant later = now.plusSeconds(300);
var fromOrder = new TokenClaims(TRUSTED_ISSUER, List.of("payment-service"), Set.of("payments:write"), later);
var forInventory = new TokenClaims(TRUSTED_ISSUER, List.of("inventory-service"), Set.of("payments:write"), later);
var readOnly = new TokenClaims(TRUSTED_ISSUER, List.of("payment-service"), Set.of("payments:read"), later);
var expired = new TokenClaims(TRUSTED_ISSUER, List.of("payment-service"), Set.of("payments:write"), now.minusSeconds(1));
for (var t : List.of(fromOrder, forInventory, readOnly, expired)) {
System.out.println(check(t, "payments:write", now));
}
}
}text
Allowed[reason=ok]
Denied[status=401, reason=token bukan untuk payment-service]
Denied[status=403, reason=scope payments:write tidak ada]
Denied[status=401, reason=token kedaluwarsa]Perhatikan bedanya: token yang tidak sah untuk penerima → 401; token sah tetapi haknya kurang → 403. Di production kamu tidak menulis ini sendiri — Spring Security resource server melakukannya, termasuk verifikasi signature lewat JWKS.
Resource server di setiap service
Setiap service menambahkan starter resource server (di Boot 4 bernama spring-boot-starter-security-oauth2-resource-server) lalu menunjuk issuer dan audience:
yaml
# application.yml payment-service
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.toko.local/realms/toko
audiences: payment-serviceDengan issuer-uri, Spring mengambil kunci publik dari endpoint JWKS issuer dan memvalidasi iss serta exp; audiences menambahkan validasi aud. Otorisasi per endpoint ditulis di SecurityFilterChain; scope di JWT otomatis menjadi authority berawalan SCOPE_:
java
// Potongan — konfigurasi security payment-service
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.POST, "/payments/**").hasAuthority("SCOPE_payments:write")
.requestMatchers(HttpMethod.GET, "/payments/**").hasAuthority("SCOPE_payments:read")
.anyRequest().denyAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // API stateless berbasis bearer token, tanpa cookie sesi
return http.build();
}
}anyRequest().denyAll() adalah bentuk least privilege: endpoint baru yang lupa didaftarkan otomatis tertutup, bukan terbuka.
Token relay vs token exchange
Saat order-service memanggil inventory-service atas nama pengguna, ada dua pilihan:
| Pola | Cara kerja | Kelebihan / kekurangan |
|---|---|---|
| Token relay | Token pengguna diteruskan apa adanya di header Authorization | Sederhana; tetapi token harus memuat semua audience hilir, dan satu token bocor berlaku di banyak service |
| Token exchange | Service menukar token pengguna ke authorization server (RFC 8693) menjadi token baru dengan aud dan scope sempit untuk service tujuan | Paling aman, identitas pengguna tetap ada; butuh dukungan authorization server dan satu round-trip tambahan |
Di gateway, token relay cukup satu filter. Contoh untuk Spring Cloud Gateway (server WebFlux) ketika gateway berperan sebagai OAuth2 client yang menangani login:
yaml
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: orders
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- TokenRelay=Relay manual di dalam service (misalnya order-service meneruskan token yang ia terima) cukup membaca Jwt dari security context:
java
// Potongan — meneruskan token pengguna ke service hilir (token relay)
@Component
class InventoryClient {
private final RestClient restClient;
InventoryClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://inventory-service").build();
}
StockView reserve(String sku, int qty) {
var auth = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
return restClient.post()
.uri("/stock/{sku}/reservations", sku)
.headers(h -> h.setBearerAuth(auth.getToken().getTokenValue()))
.body(new ReserveRequest(qty))
.retrieve()
.body(StockView.class);
}
}Spring Security juga menyediakan provider token exchange (TokenExchangeOAuth2AuthorizedClientProvider) bila authorization server-mu mendukungnya. Mulailah dengan relay, lalu naik ke exchange ketika jumlah service dan sensitivitas data bertambah.
Service-to-service dengan client credentials
Tidak semua panggilan punya pengguna: scheduled job, consumer Kafka, atau rekonsiliasi malam. Di sini service memakai identitasnya sendiri lewat grant client_credentials. Starter OAuth2 client (spring-boot-starter-security-oauth2-client di Boot 4) mengurus pengambilan, cache, dan pembaruan token:
yaml
# application.yml order-service
spring:
security:
oauth2:
client:
registration:
inventory:
provider: toko
client-id: order-service
client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: inventory:reserve
provider:
toko:
issuer-uri: https://auth.toko.local/realms/tokojava
// Potongan — RestClient yang otomatis menempelkan token client credentials
@Configuration
class ClientConfig {
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository registrations, OAuth2AuthorizedClientService clientService) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
manager.setAuthorizedClientProvider(
OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
return manager;
}
@Bean
RestClient inventoryRestClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
return builder.baseUrl("http://inventory-service")
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(manager))
.build();
}
}
// Pemakaian: pilih registration lewat request attribute
inventoryRestClient.post()
.uri("/stock/{sku}/reservations", sku)
.attributes(RequestAttributeClientRegistrationIdResolver.clientRegistrationId("inventory"))
.body(new ReserveRequest(qty))
.retrieve()
.toBodilessEntity();AuthorizedClientServiceOAuth2AuthorizedClientManager cocok untuk konteks tanpa HTTP request (job, consumer). Token di-cache sampai mendekati kedaluwarsa, jadi tidak ada round-trip ke authorization server di setiap panggilan.
Propagasi identitas & audit log
Saat memakai client credentials, sub di token adalah service, bukan pengguna. Kalau kamu tetap perlu tahu "pengguna mana yang memicu ini", bawa user id sebagai data bisnis (mis. field requestedBy di body atau event), bukan sebagai bukti otorisasi. Keputusan "boleh atau tidak" tetap berdasarkan token yang terverifikasi.
Audit log mencatat siapa melakukan apa, kapan, dan hasilnya — terpisah dari log debug, tidak boleh berisi token atau data kartu:
java
// Potongan — audit di payment-service
log.info("audit action=payment.capture orderId={} actor={} client={} result={}",
orderId, jwt.getSubject(), jwt.getClaimAsString("azp"), result);mTLS, service mesh, dan secret per service
- mTLS (mutual TLS): kedua sisi koneksi menunjukkan sertifikat, sehingga lalu lintas terenkripsi dan service tahu workload mana yang menelepon. Mengelola sertifikat per service secara manual itu berat; service mesh seperti Istio atau Linkerd memasang sidecar/proxy yang menerbitkan, merotasi, dan menegakkan mTLS otomatis. mTLS melengkapi JWT, bukan menggantikannya: mTLS menjawab "workload mana", JWT menjawab "pengguna/client mana dan hak apa".
- Secret per service: setiap service punya client id & secret sendiri (dari secret manager atau Kubernetes Secret, seperti di bab konfigurasi terpusat). Jangan berbagi satu client "internal-all" — kalau bocor, semua service ikut terbuka dan kamu tidak bisa mencabut satu saja.
- Least privilege: scope sesempit mungkin (
inventory:reserve, bukaninventory:*), akun database per service, dan token berumur pendek (5–15 menit).
Contoh di dunia backend
Alur checkout toko online dengan pola yang sudah dibahas:
text
Browser ──(login, JWT pengguna)──▶ Gateway ── TokenRelay ──▶ order-service
│ validasi JWT: aud=order-service, scope orders:write
│
client_credentials ──────┼──▶ inventory-service (scope inventory:reserve)
token exchange ──────────┴──▶ payment-service (aud=payment-service, sub=pengguna)java
// Potongan — order-service menangani checkout
@RestController
@RequestMapping("/orders")
class CheckoutController {
private final InventoryGateway inventory; // client_credentials, identitas service
private final PaymentGateway payments; // token exchange, identitas pengguna tetap terbawa
CheckoutController(InventoryGateway inventory, PaymentGateway payments) {
this.inventory = inventory;
this.payments = payments;
}
@PostMapping("/{orderId}/checkout")
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
CheckoutResult checkout(@PathVariable String orderId, @AuthenticationPrincipal Jwt user) {
inventory.reserve(orderId, user.getSubject()); // user id hanya sebagai data "requestedBy"
var payment = payments.capture(orderId); // payment-service memutuskan berdasarkan token pengguna
log.info("audit action=order.checkout orderId={} actor={} result={}",
orderId, user.getSubject(), payment.status());
return new CheckoutResult(orderId, payment.status());
}
}payment-service menerima token dengan sub pengguna asli, sehingga ia bisa memastikan pengguna hanya membayar pesanannya sendiri — sesuatu yang tidak mungkin bila semua panggilan memakai identitas service.
Pertanyaan interview
1. Kalau sudah ada API gateway yang memvalidasi token, kenapa service internal masih perlu Spring Security?
Karena gateway bukan satu-satunya jalan masuk. Pod lain di cluster, job, atau penyerang yang menguasai satu container bisa memanggil service langsung. Dengan zero trust, setiap service memverifikasi signature, issuer, audience, dan expiry sendiri, lalu mengotorisasi per endpoint. Biayanya kecil karena verifikasi JWT dilakukan lokal dengan kunci publik yang di-cache.
2. Apa beda token relay dan token exchange?
Token relay meneruskan token pengguna yang sama ke service hilir — sederhana, tetapi token harus valid untuk banyak audience dan dampak kebocorannya luas. Token exchange menukar token itu ke authorization server menjadi token baru yang khusus untuk service tujuan, dengan scope lebih sempit, sambil tetap membawa identitas pengguna. Exchange lebih aman tetapi butuh dukungan server dan satu panggilan tambahan.
3. Kapan memakai client credentials, dan apa kekurangannya?
Saat tidak ada pengguna dalam konteks: scheduled job, consumer event, integrasi antar-sistem. Tokennya mewakili service, jadi service hilir tidak tahu pengguna aslinya dan tidak bisa menerapkan aturan per pengguna. Karena itu scope client harus dibatasi ketat, dan bila keputusan bergantung pada pengguna, pakai relay/exchange.
Jebakan umum
- Hanya memvalidasi token di gateway, lalu membiarkan service internal
permitAll()"karena sudah di dalam cluster". - Tidak mengonfigurasi
audiences, sehingga token untuk service A diterima juga oleh service B. - Mempercayai header buatan sendiri seperti
X-User-IdatauX-Rolestanpa tanda tangan. - Memakai satu client id & secret bersama untuk semua service, atau menaruh secret langsung di
application.ymlyang di-commit. - Mencetak header
Authorizationatau isi token ke log/audit log. - Token berumur berjam-jam tanpa rotasi, sehingga token bocor tetap berguna lama bagi penyerang.
Ringkasan
- Zero trust: gateway menyaring lebih awal, tetapi setiap service memvalidasi JWT (signature,
iss,aud,exp) dan mengotorisasi per endpoint. - Token tidak valid untuk penerima →
401; token valid tetapi scope kurang →403; default tutup dengandenyAll(). - Token relay meneruskan token pengguna; token exchange menerbitkan token baru yang lebih sempit tanpa kehilangan identitas pengguna.
- Panggilan tanpa pengguna memakai
client_credentialsviaRestClient+OAuth2ClientHttpRequestInterceptor; token di-cache otomatis. - mTLS/service mesh mengamankan jalur antar-workload, melengkapi JWT; secret per service, scope sempit, dan audit log tanpa data sensitif menutup sisanya.