Jika aplikasi Spring Boot Anda masih mengandalkan form login berbasis session, dua area yang paling sering diremehkan adalah abuse pada endpoint autentikasi dan keamanan session setelah login berhasil. Hardening login bukan hanya soal menambah satu filter keamanan, tetapi menggabungkan beberapa kontrol yang saling melengkapi: rate limiting, kebijakan lockout yang proporsional, hashing password yang tepat, cookie session aman, rotasi session, CSRF protection, validasi input, dan audit logging.

Dalam panduan ini, kita fokus pada alur form login tradisional dengan session server-side di Spring Boot. Targetnya bukan membuat sistem yang “mustahil diserang”, melainkan menaikkan biaya serangan brute force, credential stuffing, session hijacking, dan abuse endpoint auth tanpa merusak pengalaman pengguna normal.

Threat model singkat untuk login berbasis session

Sebelum menulis kode, penting memahami ancaman yang sedang dihadapi. Dengan begitu, kontrol yang dipasang tidak asal banyak, tetapi tepat sasaran.

1. Brute force

Penyerang mencoba banyak password untuk satu akun tertentu. Biasanya pola serangan terfokus pada satu username atau email. Proteksi utama: rate limit per akun, penundaan progresif, dan lockout sementara.

2. Credential stuffing

Penyerang memakai kombinasi email/password hasil kebocoran dari layanan lain. Serangan ini sering tersebar di banyak akun dengan IP berganti-ganti. Proteksi utama: rate limit per IP dan per identitas akun, logging anomali, dan kebijakan password yang baik.

3. Session hijacking

Jika cookie session bocor atau dicuri, penyerang dapat mengambil alih akun tanpa perlu tahu password. Proteksi utama: cookie HttpOnly, Secure, SameSite, penggunaan HTTPS, dan rotasi session setelah login.

4. Abuse endpoint auth

Endpoint login bisa dijadikan target untuk menghabiskan resource server, memicu spam audit log, atau menambang informasi apakah akun tertentu ada. Proteksi utama: throttling, validasi input, respons error yang tidak membocorkan detail sensitif, dan logging yang terkontrol.

Strategi hardening: kontrol yang dipasang dan alasannya

Pendekatan yang aman biasanya berlapis. Satu kontrol tidak cukup.

  • Rate limit endpoint login untuk menahan laju percobaan.
  • Lockout sementara atau backoff progresif agar akun tidak mudah disandera, tetapi serangan tetap diperlambat.
  • Password hashing adaptif seperti BCrypt/Argon2 agar kebocoran database tidak langsung berarti kebocoran password mentah.
  • Session cookie aman agar token session sulit diakses skrip atau dikirim sembarangan.
  • Session fixation protection lewat rotasi session setelah autentikasi sukses.
  • CSRF protection untuk form login dan aksi berbasis session lain.
  • Validasi input agar request absurd cepat ditolak dan tidak memicu jalur error yang aneh.
  • Audit logging yang aman untuk investigasi insiden tanpa membocorkan password, token, atau data sensitif.

Konfigurasi dasar Spring Security untuk login berbasis session

Berikut contoh konfigurasi yang relevan untuk aplikasi Spring Boot dengan form login tradisional. Contohnya sengaja dibuat ringkas, tetapi tetap realistis.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/dashboard", true)
                .failureUrl("/login?error")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
            )
            .sessionManagement(session -> session
                .sessionFixation(sessionFixation -> sessionFixation.migrateSession())
                .maximumSessions(1)
            )
            .csrf(csrf -> csrf
                .ignoringRequestMatchers("/api/public/**")
            );

        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Poin penting dari konfigurasi di atas:

  • formLogin mengaktifkan alur form login berbasis session.
  • sessionFixation().migrateSession() membantu merotasi session setelah login berhasil, sehingga session ID lama tidak tetap dipakai.
  • CSRF tetap aktif untuk endpoint berbasis browser dan session.
  • logout menghapus session dan cookie terkait.

Jika aplikasi berada di belakang reverse proxy atau load balancer TLS termination, pastikan konfigurasi forwarded headers benar. Jika tidak, atribut seperti Secure pada cookie atau deteksi HTTPS bisa tidak konsisten.

Rate limit login di Spring Boot

Rate limit login di Spring Boot sebaiknya dipasang sedekat mungkin dengan endpoint autentikasi. Tujuannya bukan hanya memblokir serangan besar, tetapi juga membuat biaya percobaan password meningkat secara nyata.

Pilih dimensi rate limiting yang tepat

Jangan hanya membatasi berdasarkan IP. Dalam praktik, IP bisa diputar atau dibagi bersama oleh banyak pengguna sah. Gunakan kombinasi:

  • Per IP address: menahan ledakan request dari satu sumber.
  • Per username/email: menahan brute force terhadap satu akun.
  • Kombinasi IP + username: berguna untuk pola serangan tertentu.

Trade-off-nya:

  • Terlalu ketat per IP dapat mengganggu pengguna di jaringan kantor, kampus, atau NAT bersama.
  • Terlalu ketat per akun bisa dipakai penyerang untuk memicu lockout terhadap korban.

Karena itu, lebih aman memakai throttling bertahap dibanding lock permanen yang agresif.

Implementasi dengan filter sebelum autentikasi

Secara arsitektur, filter kustom dapat memeriksa request ke /login sebelum masuk ke proses autentikasi. Penyimpanan counter idealnya menggunakan store yang konsisten di banyak instance, misalnya Redis, agar limit tetap berlaku di deployment horizontal.

@Component
public class LoginRateLimitFilter extends OncePerRequestFilter {

    private final LoginAttemptService loginAttemptService;

    public LoginRateLimitFilter(LoginAttemptService loginAttemptService) {
        this.loginAttemptService = loginAttemptService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        boolean isLoginRequest = "/login".equals(request.getServletPath())
                && "POST".equalsIgnoreCase(request.getMethod());

        if (isLoginRequest) {
            String username = request.getParameter("username");
            String clientIp = extractClientIp(request);

            if (loginAttemptService.isBlocked(clientIp, username)) {
                response.setStatus(429);
                response.setContentType("text/plain;charset=UTF-8");
                response.getWriter().write("Terlalu banyak percobaan login. Coba lagi beberapa saat.");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private String extractClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Filter ini hanya memutus request bila benar-benar melewati ambang. Counter dan kebijakan batas dikelola oleh service terpisah.

Service pencatatan attempt

Contoh berikut memakai struktur in-memory untuk memudahkan pemahaman. Untuk produksi multi-instance, pindahkan ke Redis atau penyimpanan terpusat lain.

@Service
public class LoginAttemptService {

    private final Map<String, AttemptState> attempts = new ConcurrentHashMap<>();

    public boolean isBlocked(String ip, String username) {
        return isKeyBlocked("ip:" + ip) || isKeyBlocked("user:" + normalize(username));
    }

    public void onFailure(String ip, String username) {
        recordFailure("ip:" + ip);
        recordFailure("user:" + normalize(username));
    }

    public void onSuccess(String ip, String username) {
        clear("ip:" + ip);
        clear("user:" + normalize(username));
    }

    private boolean isKeyBlocked(String key) {
        AttemptState state = attempts.get(key);
        if (state == null) {
            return false;
        }
        return state.blockedUntil != null && state.blockedUntil.isAfter(Instant.now());
    }

    private void recordFailure(String key) {
        attempts.compute(key, (k, current) -> {
            Instant now = Instant.now();
            AttemptState state = current == null ? new AttemptState() : current;

            if (state.firstFailureAt == null || state.firstFailureAt.plusSeconds(300).isBefore(now)) {
                state.firstFailureAt = now;
                state.failures = 1;
                state.blockedUntil = null;
            } else {
                state.failures++;
            }

            if (state.failures >= 5) {
                state.blockedUntil = now.plusSeconds(300);
            }
            return state;
        });
    }

    private void clear(String key) {
        attempts.remove(key);
    }

    private String normalize(String username) {
        return username == null ? "" : username.trim().toLowerCase(Locale.ROOT);
    }

    private static class AttemptState {
        Instant firstFailureAt;
        int failures;
        Instant blockedUntil;
    }
}

Contoh ini menunjukkan pola umum:

  • Jendela waktu, misalnya 5 menit.
  • Ambang kegagalan, misalnya 5 kali.
  • Blokir sementara, bukan permanen.

Nilai pastinya harus disesuaikan dengan profil risiko dan pola trafik aplikasi Anda. Hindari angka yang terlalu agresif tanpa data karena bisa merusak UX.

Mengaitkan hasil autentikasi ke counter

Counter harus diperbarui saat login gagal dan dibersihkan saat login sukses. Cara yang rapi adalah memakai event atau handler dari Spring Security.

@Component
public class AuthenticationAuditHandler
        implements ApplicationListener<AbstractAuthenticationEvent> {

    private final LoginAttemptService loginAttemptService;

    public AuthenticationAuditHandler(LoginAttemptService loginAttemptService) {
        this.loginAttemptService = loginAttemptService;
    }

    @Override
    public void onApplicationEvent(AbstractAuthenticationEvent event) {
        Object details = event.getAuthentication().getDetails();
        String ip = "unknown";
        if (details instanceof WebAuthenticationDetails webDetails) {
            ip = webDetails.getRemoteAddress();
        }

        String username = event.getAuthentication().getName();

        if (event instanceof AuthenticationSuccessEvent) {
            loginAttemptService.onSuccess(ip, username);
        } else if (event instanceof AbstractAuthenticationFailureEvent) {
            loginAttemptService.onFailure(ip, username);
        }
    }
}

Jika Anda memerlukan kontrol lebih presisi, handler sukses/gagal khusus pada form login juga valid. Intinya: pencatatan dilakukan konsisten pada semua jalur autentikasi yang relevan.

Kebijakan lockout yang tidak merusak UX

Lockout sering diterapkan terlalu keras. Hasilnya, pengguna sah justru terkunci karena salah ketik beberapa kali, sementara penyerang tinggal mengganti target akun.

Prinsip yang lebih aman

  • Gunakan lockout sementara, bukan permanen otomatis.
  • Pertimbangkan backoff progresif: misalnya penundaan makin lama setelah beberapa kegagalan, sebelum benar-benar memblokir sementara.
  • Jangan ungkap detail berlebihan seperti “akun ini terkunci 12 menit lagi” jika itu memudahkan enumerasi atau profiling.
  • Sediakan jalur pemulihan seperti reset password atau bantuan support untuk kasus sah.

Pesan error yang aman

Hindari membedakan pesan “username tidak ditemukan” dan “password salah”. Gunakan pesan umum, misalnya:

  • Email atau password tidak valid.
  • Terlalu banyak percobaan login. Coba lagi beberapa saat.

Ini membantu mengurangi account enumeration.

Hashing password yang tepat

Password tidak boleh disimpan dalam bentuk plaintext atau hash cepat seperti SHA-256 tanpa mekanisme password hashing adaptif. Untuk password pengguna, gunakan encoder yang memang didesain untuk autentikasi, seperti BCrypt atau Argon2, dengan salt yang dikelola oleh algoritme tersebut.

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Mengapa ini penting?

  • Hash adaptif memperlambat percobaan cracking offline jika database bocor.
  • Salt mencegah dua password identik menghasilkan hash yang sama secara langsung.
  • Verifikasi dilakukan dengan fungsi encoder, bukan membandingkan string mentah.

Kesalahan umum:

  • Meng-hash password di frontend lalu meng-hash lagi di backend tanpa kebutuhan yang jelas.
  • Mencampur beberapa format hash tanpa strategi migrasi.
  • Mencatat password mentah ke log saat debugging.

Jika Anda memiliki sistem lama, lakukan migrasi bertahap saat pengguna login ulang, bukan me-reset semua secara mendadak kecuali insiden keamanan mengharuskan.

Session aman: cookie, rotasi session, dan masa hidup

Cookie session aman

Cookie session adalah kunci akses. Jika cookie dicuri, akun bisa diambil alih. Karena itu, pastikan atribut cookie aman diaktifkan:

  • HttpOnly: mencegah JavaScript membaca cookie secara langsung.
  • Secure: cookie hanya dikirim lewat HTTPS.
  • SameSite: membantu mengurangi pengiriman lintas situs yang tidak diinginkan.

Di level konfigurasi aplikasi, Anda biasanya perlu memastikan cookie session dikirim dengan atribut yang benar.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

SameSite=Lax sering menjadi pilihan awal yang masuk akal untuk form login tradisional. Namun, kebutuhan aplikasi bisa berbeda. Jika ada alur lintas domain tertentu, Anda mungkin perlu evaluasi lebih lanjut. Jangan mengubah ke None kecuali benar-benar dibutuhkan dan Anda memahami konsekuensi bahwa atribut Secure wajib menyertainya.

Rotasi session setelah login

Setelah autentikasi berhasil, session ID lama sebaiknya tidak terus dipakai. Ini mencegah session fixation, yaitu kondisi ketika penyerang berhasil membuat korban memakai session ID yang sudah diketahui sebelumnya.

Di Spring Security, proteksi ini biasanya diaktifkan melalui manajemen session:

.sessionManagement(session -> session
    .sessionFixation(sessionFixation -> sessionFixation.migrateSession())
)

Dengan pendekatan ini, identitas session diputar setelah login sukses.

Masa hidup session dan invalidasi

Session terlalu panjang meningkatkan risiko jika perangkat ditinggalkan atau cookie bocor. Session terlalu pendek merusak UX. Gunakan timeout yang wajar untuk profil aplikasi Anda, lalu pastikan logout benar-benar meng-invalidasi session.

Yang perlu dicek:

  • Session dihapus saat logout.
  • Cookie session ikut dibersihkan.
  • Halaman sensitif tidak tersimpan agresif di cache browser atau proxy.

CSRF untuk form login

Pada aplikasi berbasis session, CSRF protection tetap penting. Banyak tim hanya fokus pada endpoint mutasi setelah login, padahal form login sendiri juga sebaiknya mengikuti mekanisme token CSRF standar saat dirender dari server.

Jika Anda menggunakan form server-rendered dengan Spring Security, token CSRF biasanya dapat ditambahkan ke form sebagai input tersembunyi.

<form method="post" action="/login">
    <input type="hidden" name="_csrf" value="${_csrf.token}"/>
    <input type="text" name="username"/>
    <input type="password" name="password"/>
    <button type="submit">Login</button>
</form>

Kesalahan umum adalah menonaktifkan CSRF secara global hanya karena ada beberapa endpoint API. Jika aplikasi Anda campuran browser session dan API, lebih aman menonaktifkan CSRF hanya untuk endpoint yang memang tidak memerlukannya, bukan semuanya.

Validasi input pada endpoint login

Input login tampak sederhana, tetapi tetap perlu divalidasi. Tujuannya bukan hanya sanitasi klasik, tetapi juga mengurangi request tak masuk akal yang membebani autentikasi dan logging.

Praktik validasi yang disarankan

  • Batasi panjang field username/email dan password sesuai kebutuhan yang masuk akal.
  • Normalisasi input seperti trim untuk username/email bila memang sesuai aturan bisnis.
  • Jangan memantulkan input mentah ke halaman error tanpa encoding yang aman.
  • Tolak request malformed lebih awal.

Untuk login, jangan terlalu agresif memodifikasi password pengguna. Misalnya, trim() pada password bisa mengubah kredensial yang sah. Umumnya yang dinormalisasi adalah username atau email, bukan password.

Audit logging tanpa membocorkan secret

Audit log penting untuk investigasi insiden, deteksi abuse, dan analisis pola serangan. Namun, log yang terlalu detail bisa menjadi sumber kebocoran baru.

Apa yang layak dicatat

  • Waktu kejadian.
  • Username/email yang dinormalisasi atau identifier internal jika tersedia.
  • IP sumber yang sudah diproses secara konsisten.
  • Status hasil: sukses, gagal, diblokir rate limit.
  • User-Agent bila relevan.
  • Reason code internal seperti INVALID_CREDENTIALS atau RATE_LIMITED.

Apa yang jangan dicatat

  • Password mentah.
  • Hash password baru dari request registrasi/reset.
  • Session ID lengkap.
  • Token CSRF atau cookie mentah.
  • Stack trace verbose untuk error autentikasi normal.
log.info("auth_attempt result={} user={} ip={} ua={} reason={}",
    result,
    safeUsername,
    clientIp,
    safeUserAgent,
    reasonCode);

Jika log dikirim ke sistem terpusat, pastikan retensi, masking, dan kontrol aksesnya juga diperhatikan. Hardening login tidak lengkap jika log sensitif bisa dibaca terlalu luas.

Mendaftarkan filter rate limit ke rantai Spring Security

Filter rate limit harus dieksekusi sebelum filter autentikasi username/password agar request yang sudah jelas melampaui batas bisa dihentikan lebih awal.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final LoginRateLimitFilter loginRateLimitFilter;

    public SecurityConfig(LoginRateLimitFilter loginRateLimitFilter) {
        this.loginRateLimitFilter = loginRateLimitFilter;
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .addFilterBefore(loginRateLimitFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl("/dashboard", true)
                .failureUrl("/login?error")
            )
            .sessionManagement(session -> session
                .sessionFixation(sessionFixation -> sessionFixation.migrateSession())
            )
            .csrf(csrf -> csrf.withDefaults());

        return http.build();
    }
}

Kenapa di sini? Karena filter ini melindungi endpoint login dari abuse sebelum proses autentikasi mengakses storage user, encoder password, dan komponen lain yang lebih mahal secara komputasi.

Penyimpanan counter: in-memory vs Redis

In-memory

  • Kelebihan: sederhana, cepat, mudah dipahami.
  • Kekurangan: tidak konsisten pada banyak instance, hilang saat restart, kurang cocok untuk produksi berskala.

Redis atau store terpusat

  • Kelebihan: konsisten di deployment horizontal, TTL mudah diterapkan, cocok untuk rate limiting nyata.
  • Kekurangan: menambah dependensi operasional dan desain fallback.

Untuk aplikasi produksi dengan lebih dari satu instance, store terpusat biasanya lebih tepat. Kalau tetap memakai in-memory, Anda harus menerima bahwa limit dapat ter-bypass dengan berpindah instance.

Kesalahan umum saat mengamankan login

  • Hanya membatasi per IP, tanpa memperhatikan per akun.
  • Lockout permanen otomatis setelah beberapa kegagalan.
  • Menonaktifkan CSRF secara global pada aplikasi berbasis session.
  • Tidak memutar session setelah login sukses.
  • Menganggap HTTPS saja cukup tanpa cookie attributes yang benar.
  • Mencatat password atau cookie di log debug.
  • Tidak menguji perilaku di balik reverse proxy sehingga IP dan Secure cookie salah terbaca.

Checklist pengujian manual

  1. Coba login gagal berulang pada akun yang sama, pastikan rate limit atau lockout sementara aktif.
  2. Coba login gagal dari akun berbeda tetapi IP sama, pastikan limit per IP berfungsi.
  3. Pastikan pesan error tidak membedakan akun ada/tidak ada.
  4. Setelah login sukses, cek bahwa session ID berubah dibanding sebelum login.
  5. Periksa cookie session di browser: HttpOnly, Secure, dan SameSite terpasang sesuai harapan.
  6. Uji form login tanpa token CSRF atau dengan token salah, pastikan request ditolak.
  7. Logout, lalu akses ulang halaman terlindungi dengan session lama; harus gagal.
  8. Periksa log: tidak ada password, session ID penuh, atau token sensitif.
  9. Jika memakai reverse proxy, verifikasi IP client dan skema HTTPS terbaca benar.

Checklist pengujian integrasi

Untuk integrasi otomatis, fokus pada perilaku keamanan yang penting dan stabil.

  • Login sukses menghasilkan autentikasi dan session baru.
  • Login gagal berulang memicu status diblokir atau respons throttled.
  • Counter dibersihkan saat sukses jika itu bagian kebijakan Anda.
  • CSRF wajib pada POST ke login form.
  • Cookie flags muncul sesuai konfigurasi di environment pengujian yang relevan.
@SpringBootTest
@AutoConfigureMockMvc
class LoginSecurityIntegrationTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void shouldRejectLoginWithoutCsrf() throws Exception {
        mockMvc.perform(post("/login")
                .param("username", "user@example.com")
                .param("password", "wrong-pass"))
            .andExpect(status().isForbidden());
    }

    @Test
    void shouldThrottleAfterRepeatedFailures() throws Exception {
        for (int i = 0; i < 5; i++) {
            mockMvc.perform(post("/login")
                    .with(csrf())
                    .param("username", "user@example.com")
                    .param("password", "wrong-pass"));
        }

        mockMvc.perform(post("/login")
                .with(csrf())
                .param("username", "user@example.com")
                .param("password", "wrong-pass"))
            .andExpect(status().isTooManyRequests());
    }
}

Detail implementasi test bisa berbeda tergantung arsitektur login Anda, tetapi ide dasarnya sama: uji kontrol keamanan sebagai perilaku aplikasi, bukan hanya unit test method kecil.

Penutup

Hardening login berbasis session di Spring Boot paling efektif jika dilakukan sebagai kombinasi kontrol: rate limit endpoint login, lockout sementara yang proporsional, hashing password adaptif, cookie session aman, rotasi session setelah login, CSRF protection, validasi input, dan audit logging yang aman. Tidak ada satu fitur tunggal yang cukup, tetapi kombinasi ini sudah sangat kuat untuk menahan ancaman paling umum pada form login tradisional.

Jika Anda ingin memulai dari langkah yang paling berdampak, urutannya biasanya: aktifkan cookie session aman dan rotasi session, pastikan password hashing benar, tambahkan rate limit pada endpoint login, lalu rapikan CSRF, validasi, dan audit log. Dengan urutan itu, Anda mendapat peningkatan keamanan yang nyata tanpa mengubah model autentikasi aplikasi secara drastis.