Spring Security & SecurityFilterChain
Di artikel Authentication vs Authorization, kita mengenal dua tahap berurutan: membuktikan identitas (authentication) lalu menentukan hak akses (authorization). Sekarang pertanyaannya: di mana kedua tahap itu sebenarnya berjalan di dalam Spring Boot? Jawabannya ada di satu tempat: Security Filter Chain — rangkaian filter yang memotong setiap request HTTP sebelum ia menyentuh controller Anda.
Artikel ini membedah anatomi rangkaian filter tersebut: apa saja filter bawaannya, bagaimana SecurityFilterChain dikonfigurasi, di mana hasil autentikasi disimpan, kapan Anda perlu menulis filter kustom, dan jebakan-jebakan yang paling sering membuat aplikasi "aman di atas kertas" tapi bocor di kenyataan.
Kenapa Keamanan Dipasang di Edge, Bukan di Tiap Method
Bayangkan sebuah aplikasi REST biasa dengan puluhan endpoint. Anda punya dua pilihan untuk menegakkan keamanan:
- Di dalam controller / service — setiap method mengecek sendiri apakah user boleh lewat.
- Di edge — satu titik di depan aplikasi yang mengecek semua request, seperti pos satpam di gerbang gedung.
Pilihan kedua adalah pendekatan servlet. Spring Security diimplementasikan sebagai satu filter servlet yang terpasang di depan aplikasi, sebelum request mencapai DispatcherServlet dan akhirnya controller Anda. Ia bekerja persis seperti pintu keamanan di gedung: siapapun yang masuk harus lewat pos itu lebih dulu.
Browser / Klien (HTTP Request)
│
▼
┌─────────────────────────────────────────────┐
│ Servlet Container │
│ ┌───────────────────────────────────────┐ │
│ │ Security Filter Chain │ │
│ │ (satu "pintu" di depan aplikasi) │ │
│ └───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ DispatcherServlet │
│ │ │
│ ▼ │
│ Controller Anda │
└─────────────────────────────────────────────┘
Kenapa pendekatan "pintu tunggal" menang?
| Alasan | Penjelasan |
|---|---|
| Konsisten | Aturan ditulis sekali dan berlaku untuk semua endpoint tanpa terkecuali |
| Tak terlupakan | Anda tidak bisa "lupa" menambahkan cek keamanan di method baru, karena pintunya otomatis menyaring |
| Terpisah dari logika bisnis | Controller tetap fokus pada tugasnya, bukan menulis ulang logika cek role |
| Mudah diaudit | Satu tempat membaca semua kebijakan keamanan, bukan berpencar di puluhan file |
Dokumentasi resmi menyebut arsitektur ini sebagai Servlet Architecture — dan istilah kuncinya adalah filter chain.
Perumpamaan:
@PreAuthorizedi method itu seperti satpam tambahan di depan ruang server — bagus untuk aturan spesifik. TapiSecurityFilterChainadalah satpam utama di gerbang masuk yang menyaring semua orang tanpa kecuali. Anda butuh keduanya, dan yang utama harus dipasang di gerbang.
Anatomi Security Filter Chain
Spring Security menjalankan keamanan melalui deretan filter servlet yang tersusun berurutan — inilah yang disebut filter chain. Setiap request harus melewati filter pertama, lalu diteruskan ke filter berikutnya, dan seterusnya, hingga akhirnya tiba di controller.
Diagram di atas menunjukkan urutan khas filter bawaan Spring Security. Konsep kunci yang perlu dipahami adalah meneruskan atau menghentikan:
- Meneruskan — sebuah filter memanggil
chain.doFilter(request, response)untuk menyerahkan request ke filter berikutnya dalam rantai. - Menghentikan — sebuah filter menulis respons error langsung (misal
401) dan tidak memanggilchain.doFilter. Request tidak pernah lanjut, controller tidak pernah tersentuh.
// Inti dari sebuah filter servlet — perhatikan chain.doFilter()
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 1. Sesuatu sebelum diteruskan (misal: baca header, parse token)
// 2. Serahkan ke filter berikutnya (atau controller)
chain.doFilter(request, response);
// 3. Sesuatu setelah response kembali (misal: catat waktu eksekusi)
}
Urutan itu penting, dan tidak boleh sembarangan. CsrfFilter harus berjalan sebelum filter autentikasi karena CSRF token perlu diperiksa saat request mengubah state. AuthorizationFilter harus berjalan setelah identitas user tersedia — Anda tidak bisa menilai izin sebelum tahu siapa yang meminta.
Filter Bawaan yang Wajib Dikenal
Tidak semua filter perlu Anda pahami detail, tapi beberapa ini sering muncul dalam debugging dan konfigurasi:
| Urutan | Filter | Peran singkat |
|---|---|---|
| awal | CsrfFilter | Menolak request mutasi (POST/PUT/DELETE) yang tidak membawa token CSRF |
UsernamePasswordAuthenticationFilter | Membaca username/password dari form login dan membangun Authentication | |
BearerTokenAuthenticationFilter | Mem-parsing token Authorization: Bearer <JWT> menjadi Authentication | |
JwtAuthenticationFilter | Filter JWT (varian yang dipakai berbagai library tambahan) yang memvalidasi signature & masa berlaku token | |
AnonymousAuthenticationFilter | Menaruh Authentication anonim bila tidak ada filter lain yang mengisi identitas | |
ExceptionTranslationFilter | Menerjemahkan exception keamanan menjadi 401/403 (dibahas di bawah) | |
| akhir | AuthorizationFilter | Mengecek aturan authorizeHttpRequests — pengadil terakhir sebelum controller |
Catatan kecil: di Spring Security 6, resource server JWT bawaan memakai BearerTokenAuthenticationFilter. Anda juga akan menjumpai JwtAuthenticationFilter (misalnya dari library eksternal atau versi Spring Security lain) dengan peran yang sama: mengekstrak dan memvalidasi JWT lalu menyimpannya ke SecurityContext. Yang terpenting adalah hasilnya: setelah filter ini, request punya identitas.
SecurityFilterChain sebagai Konfigurasi
Semua filter itu tidak muncul begitu saja — mereka disusun oleh satu objek bernama SecurityFilterChain. Ia bekerja seperti blueprint: blueprint menentukan filter mana yang terpasang, dalam urutan apa, dan dengan pengaturan apa (misal: CSRF mati, sesi stateless, atau matcher tertentu diizinkan).
Builder HttpSecurity
Cara membangun blueprint ini adalah lewat HttpSecurity — objek builder yang menyediakan DSL untuk menyusun filter. Alur dasarnya selalu sama:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build(); // ← jangan lupa!
}
}
Dua hal yang wajib ada:
@EnableWebSecurity— menyalakan infrastruktur Spring Security dan mengaktifkan konfigurasi ini.http.build()— menutup konfigurasi dan membangun objekSecurityFilterChainyang sebenarnya. Lupa memanggilnya adalah salah satu kesalahan paling umum, yang muncul sebagai error aneh saat aplikasi dijalankan.
Referensi lengkap pola ini ada di dokumentasi resmi konfigurasi Java.
authorizeHttpRequests: Menyusun Aturan Otorisasi
Hati dari konfigurasi biasanya ada di authorizeHttpRequests. Di sinilah Anda mendeklarasikan siapa boleh mengakses URL apa. Berikut matcher yang paling sering dipakai:
| Matcher | Arti | Contoh |
|---|---|---|
.permitAll() | Siapapun boleh, tanpa autentikasi | Halaman login, halaman publik |
.authenticated() | Harus sudah login, role apa pun | Profil user, keranjang belanja |
.hasRole("ADMIN") | Harus login dan punya role ADMIN | Halaman kelola user |
.hasAnyRole("USER", "ADMIN") | Login dengan salah satu role | Area yang boleh user & admin |
.hasAuthority("user:write") | Harus punya authority spesifik (mis. scope OAuth2) | Endpoint yang dijaga permission granular |
.anyRequest().denyAll() | Semua sisanya ditolak | Penutup yang aman |
Aturan emas: tulis aturan dari yang paling spesifik ke yang paling umum, dan akhiri dengan
anyRequest().authorizeHttpRequestsmembaca aturan dari atas ke bawah dan berhenti pada kecocokan pertama. JikaanyRequest().authenticated()ditulis di paling atas, semua aturan di bawahnya tidak akan pernah dijalankan.
Contoh kombinasi:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/api/auth/login", "/api/auth/register").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**", "/api/orders/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
);
Detail selengkapnya soal authorizeHttpRequests, termasuk perbedaan hasRole vs hasAuthority, ada di dokumentasi resmi authorization.
Contoh Konfigurasi Lengkap JWT + Stateless
Untuk REST API modern yang memakai JWT, konfigurasi yang paling sering Anda temui di produksi kurang lebih seperti ini:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// API stateless tidak memakai cookie, jadi CSRF tidak relevan
.csrf(csrf -> csrf.disable())
// Jangan buat sesi server — identitas terbungkus di token
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/register",
"/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/me", "/api/orders/**").authenticated()
.anyRequest().denyAll()
)
// Terima token JWT lewat header Authorization: Bearer <token>
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(restAuthenticationEntryPoint()));
return http.build();
}
}
Perhatikan tiga pengaturan yang saling berkaitan pada aplikasi stateless:
csrf().disable()— CSRF melindungi sesi berbasis cookie. API yang hanya menerima token di header tidak memakai cookie, sehingga proteksi ini dimatikan (dan ini memang rekomendasi resmi untuk REST API).SessionCreationPolicy.STATELESS— server tidak menyimpan sesi di memori. Setiap request berdiri sendiri; identitas datang dari token yang dikirim klien.oauth2ResourceServer(...)— memberitahu Spring Security agar memvalidasi JWT di headerAuthorization. Referensi lengkapnya ada di dokumentasi resmi servlet authentication.
Authentication Context: Ke Mana Hasil Autentikasi Disimpan
Setelah BearerTokenAuthenticationFilter memvalidasi token, ke mana identitas user itu disimpan? Jawabannya: SecurityContext.
SecurityContext adalah wadah per-request yang menyimpan objek Authentication — yang di dalamnya ada principal (siapa usernya) dan authorities (daftar role/permission). Spring Security menempelkannya pada thread yang sedang menangani request. Selama request itu masih diproses, objek Authentication bisa diakses dari mana saja di thread tersebut.
Dua cara mengaksesnya di controller:
// Cara 1: deklaratif — paling disarankan
@GetMapping("/me")
public UserDto me(@AuthenticationPrincipal UserDetails userDetails) {
return userService.toDto(userDetails);
}
// Cara 2: langsung dari SecurityContextHolder
@GetMapping("/me")
public UserDto me() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(g -> g.getAuthority().equals("ROLE_ADMIN"));
return new UserDto(username, isAdmin);
}
Peringatan penting soal thread:
SecurityContextbersifat thread-local — artinya ia hidup di thread yang menangani request itu. Jika Anda memindahkan pekerjaan ke thread lain (misalCompletableFuture.supplyAsync(...)), konteks keamanan tidak ikut terbawa. Anda perlu menyalinnya secara eksplisit (atau men-delegasikan lewatDelegatingSecurityContextCallable) jika code Anda butuh autentikasi di thread async.
Menambahkan Filter Kustom
Default Spring Security mencakup banyak hal, tapi kadang Anda butuh filter yang tidak disediakan: mencatat log per request, menyuntikkan header, mengekstrak tenant id untuk multi-tenant, atau integrasi dengan sistem identitas lama.
Aturan praktis yang baik: tulis filter kustom hanya jika pekerjaan itu memang urusan lintas-endpoint. Menaruh logika bisnis di filter adalah jebakan klasik — filter bukan tempat memproses data, melainkan tempat melakukan sesuatu sebelum atau sesudah request diproses.
Contoh filter logging sederhana dengan OncePerRequestFilter:
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
log.info("{} {} -> {} ({} ms)",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
System.currentTimeMillis() - start);
}
}
}
Lalu daftarkan filter tersebut di SecurityFilterChain dengan menentukan posisinya. Spring Security menyediakan addFilterBefore, addFilterAfter, dan addFilterAt:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(requestLoggingFilter, UsernamePasswordAuthenticationFilter.class)
// ... aturan authorizeHttpRequests lainnya
;
return http.build();
}
Mengapa posisi penting? Filter logging boleh di awal. Tapi filter yang membutuhkan identitas user (misalnya memeriksa SecurityContext) harus dipasang setelah filter autentikasi, sementara filter yang memengaruhi autentikasi itu sendiri harus dipasang sebelum filter autentikasi. Memilih posisi salah berarti filter Anda melihat SecurityContext yang masih kosong.
ExceptionTranslationFilter: Sumber dari 401 dan 403
Pernah bertanya kenapa request tanpa token dibalas 401 dan yang salah role dibalas 403, padahal Anda tidak menulis kode untuk itu? Itu kerja ExceptionTranslationFilter.
Ia bekerja seperti penerjemah: menangkap exception keamanan yang dilempar filter lain dan mengubahnya menjadi respons HTTP yang benar.
| Exception | Status code | Artinya |
|---|---|---|
AuthenticationException | 401 Unauthorized | Autentikasi gagal — token tidak ada/valid/kadaluarsa |
AccessDeniedException | 403 Forbidden | Autentikasi sukses tapi user tidak punya hak |
ExceptionTranslationFilter melibatkan dua komponen yang sering disalahartikan:
| Komponen | Kapan dipanggil | Peran |
|---|---|---|
AuthenticationEntryPoint | Saat request anonim membutuhkan autentikasi (gagal 401) | "Mulai proses login" — untuk REST API biasanya mengembalikan 401 + JSON error |
AccessDeniedHandler | Saat user sudah login tapi tidak diizinkan (gagal 403) | "Beri tahu user bahwa akses ditolak" — biasanya 403 + JSON error |
Karena aplikasi REST tidak punya halaman login untuk dialihkan, keduanya biasanya dikonfigurasi untuk mengembalikan JSON. Detail arsitektur ini dijelaskan di dokumentasi resmi servlet architecture.
@PreAuthorize vs URL Matcher: Kapan Memakai Mana
authorizeHttpRequests bukan satu-satunya alat. Ada juga anotasi @PreAuthorize di level method. Bagaimana membedakan kapan memakainya?
| Aspek | URL matcher (authorizeHttpRequests) | Method security (@PreAuthorize) |
|---|---|---|
| Cakupan | Berlaku untuk pola URL | Berlaku untuk method tertentu |
| Lokasi | Terpusat di SecurityConfig | Menyebar di controller/service |
| Granularitas | Kasar (per path) | Halus (per method, bisa cek argumen) |
| Keliru membuat aturan baru | Tinggal edit satu file | Tersebar, mudah terlewat |
| Cek berbasis argumen/objek | Tidak bisa | Bisa (#id == principal.id) |
| Performa | Dievaluasi di filter (lebih awal) | Dievaluasi saat method dipanggil |
Panduan praktisnya:
- Gunakan URL matcher untuk aturan global yang jelas per path: halaman publik, area admin, API yang melindungi resource.
- Gunakan
@PreAuthorizeuntuk aturan spesifik yang melibatkan detail request — misalnya "user hanya boleh menghapus resource miliknya sendiri" yang butuh membandingkan id:
@DeleteMapping("/orders/{id}")
@PreAuthorize("hasRole('USER') and #id == authentication.principal.id")
public void cancelOrder(@PathVariable Long id) {
orderService.cancel(id);
}
Keduanya saling melengkapi: URL matcher sebagai lapis pertama di gerbang, @PreAuthorize sebagai penjaga presisi di dalam ruangan. Referensi lengkap ada di dokumentasi resmi authorizeHttpRequests.
Jebakan Umum
Menulis Spring Security di produksi penuh lubang. Berikut yang paling sering saya temui — termasuk dari kode saya sendiri:
| Jebakan | Kenapa berbahaya | Perbaikan |
|---|---|---|
| Urutan matcher salah (aturan umum di atas aturan spesifik) | anyRequest() dulu, sisanya tak pernah dieksekusi | Tulis spesifik → umum, anyRequest selalu terakhir |
Lupa http.build() | Konfigurasi tidak terbentuk; aplikasi error atau memakai default | Selalu akhiri dengan return http.build(); |
| Menaruh logika bisnis di filter | Aturan bisnis tersembunyi di pipeline, sulit dites & diaudit | Filter hanya untuk tugas lintas-endpoint; logika bisnis di service |
Mengakses SecurityContext di thread async | Thread baru tidak membawa konteks; hasilnya null atau salah user | Salin SecurityContext secara eksplisit ke thread baru |
| Menonaktifkan CSRF untuk semua aplikasi | csrf.disable() tanpa alasan jelas membuka celah di aplikasi berbasis cookie/sesi | Matikan hanya untuk REST API stateless; jelaskan alasannya di komentar |
Menggunakan permitAll() untuk endpoint yang butuh identitas | Berpura-pura aman padahal SecurityContext kosong di sana | Ingat: permitAll = bebas akses, bukan "identitas otomatis ada" |
Satu lagi yang sering mengecoh:
// BURUK: token invalid "dibiarkan" — endpoint authenticated() akan menolak,
// tapi kenapa 403 muncul alih-alih 401? Cek AccessDeniedHandler!
Jika API Anda membalas 403 untuk token yang hilang padahal seharusnya 401, hampir selalu karena AuthenticationEntryPoint tidak dikonfigurasi untuk path tersebut, sehingga ExceptionTranslationFilter memilih jalur yang salah. Uji dengan tiga skenario: tanpa token, token invalid, dan token valid tapi role salah — status code-nya harus 401, 401, 403.
Ringkasan
Spring Security adalah filter servlet yang dipasang di edge, di depan DispatcherServlet — satu pintu yang memastikan aturan keamanan konsisten dan tidak terlupakan. Pintu itu sendiri adalah SecurityFilterChain: deretan filter yang masing-masing bisa meneruskan request (chain.doFilter) atau menghentikannya dengan respons error. Filter bawaannya (CSRF, autentikasi, anonymous, exception translation, authorization) disusun oleh builder HttpSecurity dan dikonfigurasi lewat authorizeHttpRequests dengan aturan dari spesifik ke umum. Hasil autentikasi disimpan di SecurityContext (thread-local) dan diakses lewat @AuthenticationPrincipal atau SecurityContextHolder. Saat butuh perilaku khusus, Anda bisa menambahkan filter kustom (OncePerRequestFilter) pada posisi yang tepat, dan mengatur AuthenticationEntryPoint (401) vs AccessDeniedHandler (403) untuk respons error yang benar. Gunakan authorizeHttpRequests untuk aturan per-URL dan @PreAuthorize untuk aturan presisi per-method.
Lanjut membaca
- Dokumentasi resmi Spring Security — Servlet Architecture
- Dokumentasi resmi Spring Security — Servlet Authentication
- Dokumentasi resmi Spring Security — Authorize HTTP Requests
- Dokumentasi resmi Spring Security — Java Configuration
- Kembali ke dasar: Authentication vs Authorization
- Lanjut ke mekanisme token: JWT (JSON Web Token) untuk Autentikasi