CORS, Rate Limiting, reCAPTCHA & Timing Attack

12 min readIntermediate
SecurityCORSRate LimitingreCAPTCHA

API publik Anda akan diakses dari berbagai tempat: aplikasi web di browser, aplikasi mobile, sampai script otomatis. Semakin publik sebuah API, semakin terbuka pintunya terhadap penyalahgunaan. Artikel ini membahas empat lapis pertahanan yang saling melengkapi: CORS agar browser mau memanggil API lintas-domain dengan aman, rate limiting agar API tidak dihantam request berlebihan, reCAPTCHA agar bot tidak bisa mendaftar/masuk lewat form, dan timing attack — kelemahan halus yang membuat perbandingan data sensitif bisa ditebak hanya dari selisih waktu respons.

CORS (Cross-Origin Resource Sharing)

Masalahnya: Same-Origin Policy

Browser punya aturan keamanan bernama Same-Origin Policy (SOP): halaman dari satu origin tidak boleh membaca respons dari origin lain tanpa izin eksplisit. Sebuah origin dibentuk dari tiga bagian: scheme + host + port.

OriginSchemeHostPort
https://toko.comhttpstoko.com443 (default)
http://toko.com:8080httptoko.com8080

Dua URL disebut same-origin hanya jika ketiga bagian itu sama persis. https://toko.com dan http://toko.com berbeda (scheme beda), begitu juga https://toko.com dan https://toko.com:8443 (port beda).

Bayangkan halaman https://admin.panel.com mencoba memanggil https://api.backend.com/users. Itu adalah request lintas-origin. Karena SOP, browser akan memblokirnya — padahal request tersebut mungkin sah, misalnya frontend di domain terpisah memang sengaja dibuat untuk memanggil API. Di sinilah CORS menjadi penengah.

Cara Kerja CORS

CORS (Cross-Origin Resource Sharing) adalah mekanisme HTTP yang memberi tahu browser: "origin ini boleh membaca respons saya". Server menyatakan izin itu lewat header khusus pada response.

Untuk request yang "kompleks", browser mengirim preflight request dulu: sebuah OPTIONS ke server untuk bertanya izin sebelum request aslinya dikirim.

Browser                                Server API
   │                                      │
   │  OPTIONS /api/users                  │
   │  Origin: https://admin.panel.com     │
   │  Access-Control-Request-Method: GET  │
   │  Access-Control-Request-Headers: auth│
   │──────────────────────────────────────►│
   │                                      │
   │  Access-Control-Allow-Origin: *      │
   │  Access-Control-Allow-Methods: GET   │
   │  Access-Control-Allow-Headers: auth  │
   │◄──────────────────────────────────────│
   │                                      │
   │  GET /api/users (request asli)       │
   │──────────────────────────────────────►│
HeaderArahFungsi
OriginRequestMenyebutkan origin yang memanggil (diisi browser otomatis)
Access-Control-Allow-OriginResponseOrigin mana yang diizinkan; * artinya semua
Access-Control-Allow-MethodsResponseMethod yang diizinkan (GET, POST, dst.)
Access-Control-Allow-HeadersResponseHeader kustom yang boleh dikirim klien (misal Authorization)
Access-Control-Allow-CredentialsResponseApakah cookie/kredensial boleh dikirim lintas-origin
Access-Control-Max-AgeResponseLama hasil preflight boleh di-cache browser

Kapan Preflight Terpicu?

Tidak semua request perlu preflight. Request sederhana — method GET/POST/HEAD tanpa header kustom selain yang standar — langsung dikirim tanpa bertanya dulu. Request menjadi kompleks (dan memicu preflight) jika memakai method non-sederhana seperti PUT, PATCH, DELETE, atau memakai header kustom seperti Authorization.

Jenis RequestContohPreflight?
SederhanaGET /api/products dari frontendTidak
SederhanaPOST /api/search dengan Content-Type: application/x-www-form-urlencodedTidak
KompleksGET /api/users dengan header AuthorizationYa
KompleksDELETE /api/users/42Ya

Poin penting: preflight bukan keamanan. Ia hanyalah mekanisme perizinan browser. Server yang salah konfigurasi CORS tidak "lebih aman" — ia hanya membuat frontend browser tidak bisa dipakai.

Implementasi di Spring

Spring Framework menyediakan dua cara utama mengonfigurasi CORS: WebMvcConfigurer.addCorsMappings untuk mapping global, dan CorsConfigurationSource yang bisa dipasangkan ke CorsFilter bila Anda ingin aturan yang lebih terpusat.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://admin.panel.com")
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("Authorization", "Content-Type")
                .allowCredentials(true)
                .maxAge(3600);
    }
}
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://admin.panel.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

Jangan menulis .allowedOrigins("*") bersamaan dengan .allowCredentials(true). Keduanya bertentangan: bila server mengizinkan semua origin sambil menerima kredensial, browser menolak responsnya. Kombinasi ini juga membuka celah — situs jahat mana pun bisa mengirim request yang membawa cookie Anda. Sebutkan origin yang Anda kenal secara eksplisit.

Jangan Mengandalkan CORS sebagai Keamanan

Ini jebakan paling umum. CORS hanya membatasi browser. Alat seperti curl, Postman, atau aplikasi mobile sama sekali tidak peduli dengan header CORS.

curl -X POST https://api.backend.com/api/users \
     -H "Authorization: Bearer <token>" \
     -d '{"email":"bot@mail.com","role":"ADMIN"}'

Perintah di atas berjalan mulus walau server menolak semua origin — karena curl tidak menerapkan SOP. Artinya, CORS tidak pernah menjadi pengganti autentikasi dan otorisasi. CORS menjawab pertanyaan "bolehkah halaman web ini membaca respons?", bukan "apakah pemanggil ini memang dia?". Untuk yang terakhir, Anda tetap butuh Authorization, session, dan seluruh aturan di Spring Security Filter Chain.

Rate Limiting

Apa Itu dan Kenapa Perlu

Rate limiting adalah aturan yang membatasi berapa banyak request yang boleh dikirim dalam jangka waktu tertentu. Tanpa rate limiting, endpoint publik Anda bisa menjadi sasaran:

AncamanContohDampak
Brute-force loginMencoba ribuan password ke POST /api/loginAkun pengguna terbobol
Scraping / penyalahgunaanMenguras endpoint pencarian dengan botBiaya bandwidth dan database naik
Denial of ServiceMembanjiri server dengan requestLayanan melambat hingga tumbang

Pendekatan OWASP menyebutkan bahwa mitigasi seperti rate limiting adalah bagian dari pertahanan terhadap DoS: kita tidak bisa memblokir semua trafik, tapi kita bisa membatasi dampaknya per pemanggil.

Tingkat Penerapan Rate Limit

StrategiContohCocok untuk
Per-IPMaksimal 100 request/menit per alamat IPMemperlambat bot anonim
Per-userMaksimal 30 request/menit per akun yang loginMenjaga fairness antar pengguna
Per-endpointEndpoint login dibatasi 10/menit, endpoint GET boleh 1000/menitMelindungi jalur sensitif

Ketiganya bisa dipasang bersamaan. Aturan praktisnya: makin sensitif endpoint-nya, makin ketat batasnya.

Strategi Algoritma

AlgoritmaKonsepKelebihanKekurangan
Fixed windowBatas direset tiap jendela waktu tetap (misal tiap menit)Sederhana, mudah dipahamiDua jendela berdekatan bisa digabung bot jadi 2x kuota
Sliding windowJendela digeser sesuai waktu request terakhirLebih adil, tidak bisa dieksploitasi di tepi jendelaSedikit lebih kompleks
Token bucketBak berisi token; setiap request mengambil 1 token, token diisi kembali bertahapMampu menangani ledakan singkat (burst)Perlu penyimpanan state
Leaky bucketRequest diproses dengan laju tetap, sisanya diantrekan/dibuangLaju keluar sangat stabilRequest mendadak bisa banyak dibuang

Jangan terjebak terlalu dalam — pahami konsepnya, lalu pilih berdasarkan kebutuhan. Untuk sebagian besar aplikasi, token bucket (misal via library bucket4j) sudah cukup fleksibel.

Implementasi Sederhana di Spring

Cara paling ringan adalah menulis HandlerInterceptor (atau servlet Filter) yang menghitung request per kunci (misal IP) di dalam penyimpanan sementara seperti ConcurrentHashMap dengan jendela waktu.

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final ConcurrentHashMap<String, Deque<Long>> requests = new ConcurrentHashMap<>();
    private static final int MAX_REQUESTS = 100;
    private static final long WINDOW_MILLIS = 60_000L;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws IOException {
        String key = request.getRemoteAddr();
        long now = System.currentTimeMillis();

        Deque<Long> stamps = requests.computeIfAbsent(key, k -> new ArrayDeque<>());
        synchronized (stamps) {
            while (!stamps.isEmpty() && now - stamps.peekFirst() > WINDOW_MILLIS) {
                stamps.pollFirst();
            }
            if (stamps.size() >= MAX_REQUESTS) {
                response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
                response.setHeader("Retry-After", "60");
                response.getWriter().write("{\"error\":\"TOO_MANY_REQUESTS\"}");
                return false;
            }
            stamps.addLast(now);
        }
        return true;
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns("/api/**");
    }
}

Perhatikan dua hal di atas: saat batas terlampaui kita membalas 429 Too Many Requests (status HTTP khusus untuk ini) dan menyertakan header Retry-After yang memberi tahu klien berapa detik lagi boleh mencoba. Untuk produksi yang butuh skala, gunakan penyimpanan terdistribusi seperti Redis — misalnya via @EnableRedisHttpSession atau library bucket4j dengan backend Redis — karena ConcurrentHashMap hanya bekerja untuk satu instance aplikasi.

Best Practice

Jangan menyamaratakan semua endpoint. Endpoint autentikasi seperti POST /api/login dan POST /api/register harus jauh lebih ketat — contoh umum 10 request per menit per IP — karena di sinilah serangan brute-force paling berbahaya. Sebaliknya, jangan membatasi pengguna sah secara agresif tanpa alasan; rate limit yang terlalu ketat di endpoint pembacaan umum justru merusak pengalaman aplikasi yang sah. Awasi log dan sesuaikan angka batasnya dengan pola trafik nyata.

reCAPTCHA

Kenapa Dipakai

Rate limiting memperlambat bot, tapi bot yang punya banyak IP masih bisa berjalan. Untuk form publik (registrasi, login, kontak) kita ingin memisahkan manusia dari bot secara lebih tegas. Di sinilah reCAPTCHA masuk: sebuah layanan dari Google yang menilai sebuah interaksi di browser.

JenisCara KerjaKapan Cocok
Checkbox ("I'm not a robot")Pengguna mencentang kotak; Google menilai perilakunyaForm login, registrasi, komentar
InvisibleTidak ada widget; Google menilai perilaku pengguna diam-diamForm yang ingin minim gesekan
EnterpriseSkor & analitik lanjutan untuk aplikasi besarAplikasi dengan risiko tinggi

Google mengembalikan score antara 0 dan 1; 0 berarti hampir pasti bot, 1 berarti hampir pasti manusia. Backend Anda yang memutuskan ambang batas skor (misal tolak jika skor < 0.5).

Alur Kerja

Kunci keamanan reCAPTCHA adalah: verifikasi selalu dilakukan di backend, bukan di frontend. Browser hanya memproduksi token; backend yang menanyakan ke Google.

Browser (klien)                Backend Anda                  Google API
      │                              │                            │
      │ 1. Isi form + reCAPTCHA      │                            │
      │    menghasilkan token        │                            │
      │──────────────────────────────►│                            │
      │ 2. POST /api/register         │                            │
      │    { token, data }            │                            │
      │──────────────────────────────►│                            │
      │                              │ 3. POST /siteverify        │
      │                              │    secret + token          │
      │                              │───────────────────────────►│
      │                              │ 4. { success, score }      │
      │                              │◄───────────────────────────│
      │ 5. 200 OK / 400 Bad Request  │                            │
      │◄──────────────────────────────│                            │

Langkah-langkahnya:

  1. Halaman frontend memuat widget reCAPTCHA dan menyisipkan token g-recaptcha-response ke dalam form.
  2. Backend menerima token itu bersama data form yang dikirim.
  3. Backend memanggil endpoint verifikasi Google (POST https://www.google.com/recaptcha/api/siteverify) dengan mengirim secret key dan token tadi.
  4. Google membalas success (apakah token valid) dan score (seberapa yakin interaksinya manusiawi).
  5. Backend memutuskan: tolak jika token tidak valid atau skor di bawah ambang, lanjutkan jika lolos.
// Gambaran umum pemanggilan verifikasi di backend
public boolean verifyRecaptcha(String token) {
    RestClient client = RestClient.create();
    RecaptchaResponse res = client.post()
            .uri("https://www.google.com/recaptcha/api/siteverify")
            .form(form -> form
                    .param("secret", recaptchaSecret)   // secret dari env, bukan dari frontend
                    .param("response", token))
            .retrieve()
            .body(RecaptchaResponse.class);

    return res.success() && res.score() >= 0.5;
}

Jangan pernah menaruh secret reCAPTCHA (atau secret apa pun) di kode frontend. Secret adalah kata sandi server — begitu masuk ke bundle JavaScript, semua orang bisa melihatnya. Simpan di environment variable di sisi backend.

reCAPTCHA bukan pengganti rate limiting dan autentikasi. Ia menangkal bot di lapisan interaksi browser, sementara rate limiting tetap menangkal pemanggil langsung yang tidak lewat browser sama sekali.

Timing Attack

Apa Itu Timing Attack

Bayangkan membandingkan dua string. Kode a.equals(b) membandingkan karakter demi karakter dari kiri, dan berhenti segera saat menemukan karakter yang berbeda (perilaku short-circuit). Artinya: membandingkan token yang salah di karakter pertama jauh lebih cepat daripada token yang salah di karakter terakhir.

Selisihnya hanya beberapa nanodetik, tapi bisa diukur berulang kali. Dari perbedaan waktu respons inilah penyerang menebak isi data rahasia — token, password, atau MAC — satu karakter demi satu karakter, mirip menebak kombinasi brankas dari suara klik kunci. Inilah yang disebut timing attack.

// RENTAN: berhenti saat karakter pertama berbeda
boolean insecureCompare(String a, String b) {
    return a.equals(b);
}

// RENTAN: memakai == untuk nilai yang datang dari luar
if (receivedToken == expectedToken) {
    // ...
}

Solusi: Perbandingan Constant-Time

Tujuannya sederhana: waktu perbandingan harus identik terlepas dari di karakter ke berapa data berbeda. Cara paling mudah di Java adalah MessageDigest.isEqual — metode yang membandingkan dua byte[] dengan total waktu yang konstan.

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;

public class TokenService {

    public boolean verifyToken(String received, String expected) {
        byte[] a = received.getBytes(StandardCharsets.UTF_8);
        byte[] b = expected.getBytes(StandardCharsets.UTF_8);
        return MessageDigest.isEqual(a, b); // constant-time
    }

    public String generateToken() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return java.util.HexFormat.of().formatHex(bytes);
    }
}

Catatan praktis: MessageDigest.isEqual membandingkan dua array dengan panjang yang sama; bila panjangnya berbeda ia langsung mengembalikan false. Untuk skenario yang butuh waktu konstan mutlak bahkan pada panjang yang beda, bandingkan hash (SHA-256) dari kedua input terlebih dahulu. Yang penting: hindari == dan equals() untuk perbandingan nilai rahasia.

Pertahanan Pendukung

Timing attack bukan satu-satunya jalan menebak data rahasia, dan tidak berdiri sendiri. Lengkapi dengan:

  1. Pesan error yang seragam — jangan bedakan respons "email tidak terdaftar" vs "password salah". Kalau pesannya beda, penyerang tinggal mengumpulkan email yang valid. Balas dengan pesan yang sama persis untuk kedua kasus.
  2. Hashing yang lambat — simpan password sebagai hash dari algoritma yang sengaja dibuat lambat seperti BCrypt (lewat PasswordEncoder bawaan Spring Security). Dokumentasi Spring Security menyarankan BCrypt untuk penyimpanan password, karena setiap hash yang lambat memperlambat kecepatan brute-force secara drastis. Hash ini juga dikombinasikan dengan salt acak per user.
  3. Jangan pernah membandingkan password mentah dengan equals. Selalu bandingkan hasil hash yang sudah disimpan, dan untuk perbandingan sensitif lainnya gunakan MessageDigest.isEqual.

Ringkasan

Keempat teknik ini menjawab masalah yang berbeda dan saling melengkapi. Menyatukan semuanya adalah contoh nyata defense in depth — berlapis-lapis, sehingga satu lapis yang gagal masih ditahan lapis berikutnya.

LapisanMenangkalTitik Pemasangan
CORSBlokir pembacaan lintas-origin yang tidak diizinkan di browserKonfigurasi server
Rate limitingBanjir request, brute-force, awal mula DoSFilter/interceptor di edge
reCAPTCHABot yang mengisi form publikHalaman frontend + verifikasi backend
Autentikasi & hashingPenebakan password (timing attack, brute-force)Spring Security + PasswordEncoder
Perbandingan constant-timeKebocoran data lewat selisih waktuKode verifikasi token/hash

Ingat urutan pemikirannya: jangan pernah mengandalkan satu lapisan saja. CORS bukan keamanan, rate limiting bukan perlindungan penuh, reCAPTCHA hanya menjangkau browser, dan timing attack bisa menembus logika perbandingan yang "terlihat benar". Kombinasikan semuanya di belakang autentikasi yang benar.

Lanjut membaca:

← Back to technical articles