Spring Security & SecurityFilterChain

13 min readIntermediate
Spring SecurityFilterSecurityBackend

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:

  1. Di dalam controller / service — setiap method mengecek sendiri apakah user boleh lewat.
  2. 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?

AlasanPenjelasan
KonsistenAturan ditulis sekali dan berlaku untuk semua endpoint tanpa terkecuali
Tak terlupakanAnda tidak bisa "lupa" menambahkan cek keamanan di method baru, karena pintunya otomatis menyaring
Terpisah dari logika bisnisController tetap fokus pada tugasnya, bukan menulis ulang logika cek role
Mudah diauditSatu 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: @PreAuthorize di method itu seperti satpam tambahan di depan ruang server — bagus untuk aturan spesifik. Tapi SecurityFilterChain adalah 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.

Spring Security Filter Chain

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 memanggil chain.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:

UrutanFilterPeran singkat
awalCsrfFilterMenolak request mutasi (POST/PUT/DELETE) yang tidak membawa token CSRF
UsernamePasswordAuthenticationFilterMembaca username/password dari form login dan membangun Authentication
BearerTokenAuthenticationFilterMem-parsing token Authorization: Bearer <JWT> menjadi Authentication
JwtAuthenticationFilterFilter JWT (varian yang dipakai berbagai library tambahan) yang memvalidasi signature & masa berlaku token
AnonymousAuthenticationFilterMenaruh Authentication anonim bila tidak ada filter lain yang mengisi identitas
ExceptionTranslationFilterMenerjemahkan exception keamanan menjadi 401/403 (dibahas di bawah)
akhirAuthorizationFilterMengecek 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 objek SecurityFilterChain yang 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:

MatcherArtiContoh
.permitAll()Siapapun boleh, tanpa autentikasiHalaman login, halaman publik
.authenticated()Harus sudah login, role apa punProfil user, keranjang belanja
.hasRole("ADMIN")Harus login dan punya role ADMINHalaman kelola user
.hasAnyRole("USER", "ADMIN")Login dengan salah satu roleArea yang boleh user & admin
.hasAuthority("user:write")Harus punya authority spesifik (mis. scope OAuth2)Endpoint yang dijaga permission granular
.anyRequest().denyAll()Semua sisanya ditolakPenutup yang aman

Aturan emas: tulis aturan dari yang paling spesifik ke yang paling umum, dan akhiri dengan anyRequest(). authorizeHttpRequests membaca aturan dari atas ke bawah dan berhenti pada kecocokan pertama. Jika anyRequest().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 header Authorization. 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: SecurityContext bersifat thread-local — artinya ia hidup di thread yang menangani request itu. Jika Anda memindahkan pekerjaan ke thread lain (misal CompletableFuture.supplyAsync(...)), konteks keamanan tidak ikut terbawa. Anda perlu menyalinnya secara eksplisit (atau men-delegasikan lewat DelegatingSecurityContextCallable) 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.

ExceptionStatus codeArtinya
AuthenticationException401 UnauthorizedAutentikasi gagal — token tidak ada/valid/kadaluarsa
AccessDeniedException403 ForbiddenAutentikasi sukses tapi user tidak punya hak

ExceptionTranslationFilter melibatkan dua komponen yang sering disalahartikan:

KomponenKapan dipanggilPeran
AuthenticationEntryPointSaat request anonim membutuhkan autentikasi (gagal 401)"Mulai proses login" — untuk REST API biasanya mengembalikan 401 + JSON error
AccessDeniedHandlerSaat 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?

AspekURL matcher (authorizeHttpRequests)Method security (@PreAuthorize)
CakupanBerlaku untuk pola URLBerlaku untuk method tertentu
LokasiTerpusat di SecurityConfigMenyebar di controller/service
GranularitasKasar (per path)Halus (per method, bisa cek argumen)
Keliru membuat aturan baruTinggal edit satu fileTersebar, mudah terlewat
Cek berbasis argumen/objekTidak bisaBisa (#id == principal.id)
PerformaDievaluasi 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 @PreAuthorize untuk 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:

JebakanKenapa berbahayaPerbaikan
Urutan matcher salah (aturan umum di atas aturan spesifik)anyRequest() dulu, sisanya tak pernah dieksekusiTulis spesifik → umum, anyRequest selalu terakhir
Lupa http.build()Konfigurasi tidak terbentuk; aplikasi error atau memakai defaultSelalu akhiri dengan return http.build();
Menaruh logika bisnis di filterAturan bisnis tersembunyi di pipeline, sulit dites & diauditFilter hanya untuk tugas lintas-endpoint; logika bisnis di service
Mengakses SecurityContext di thread asyncThread baru tidak membawa konteks; hasilnya null atau salah userSalin SecurityContext secara eksplisit ke thread baru
Menonaktifkan CSRF untuk semua aplikasicsrf.disable() tanpa alasan jelas membuka celah di aplikasi berbasis cookie/sesiMatikan hanya untuk REST API stateless; jelaskan alasannya di komentar
Menggunakan permitAll() untuk endpoint yang butuh identitasBerpura-pura aman padahal SecurityContext kosong di sanaIngat: 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

← Back to technical articles