Kenapa ini penting
Autentikasi menjawab "kamu siapa?". Otorisasi (authorization) menjawab pertanyaan berikutnya: "kamu boleh melakukan apa?". Dua hal ini sering tertukar, padahal berbeda. Kembali ke analogi hotel: kartu kamar membuktikan kamu tamu hotel (autentikasi). Tapi kartu tamu kamar 305 tidak boleh membuka kamar 306, dan tidak boleh masuk ruang server hotel (otorisasi).
Celah otorisasi adalah celah nomor satu di daftar OWASP API Security Top 10, dengan nama BOLA (Broken Object Level Authorization). Contohnya sangat sederhana: Budi membuka /api/orders/15 dan melihat pesanannya. Iseng, dia mengganti angkanya jadi /api/orders/16, dan ternyata muncul pesanan milik orang lain lengkap dengan alamat dan nomor teleponnya. Server sudah memeriksa "apakah Budi login?" tapi lupa memeriksa "apakah pesanan 16 milik Budi?". Kebocoran data besar di berbagai negara pernah terjadi persis dengan pola ini.
Konsep inti
401 vs 403 (dan kadang 404)
Status code HTTP membedakan dua jenis penolakan:
| Status | Nama | Arti | Contoh |
|---|---|---|---|
| 401 | Unauthorized (sebenarnya "unauthenticated") | Server tidak tahu kamu siapa. Login dulu. | Tidak ada cookie, token rusak, token kedaluwarsa |
| 403 | Forbidden | Server tahu kamu siapa, tapi kamu tidak punya hak | User biasa mencoba membuat produk |
| 404 | Not Found | Data tidak ada, atau sengaja disembunyikan | User mencoba membuka pesanan orang lain |
Nama "401 Unauthorized" memang membingungkan karena maknanya sebenarnya "belum terautentikasi". Ingat saja: 401 = siapa kamu?, 403 = aku tahu kamu, tapi tidak boleh.
Kenapa untuk data milik orang lain lebih baik membalas 404 daripada 403? Karena 403 diam-diam mengonfirmasi bahwa "pesanan nomor 16 itu ada". Penyerang bisa memakai informasi ini untuk memetakan data. Dengan 404, dari sudut pandang Budi, pesanan orang lain memang "tidak ada". Ini pilihan desain yang direkomendasikan untuk resource milik pengguna. Untuk fitur yang memang dikenal publik (misalnya endpoint admin), 403 tetap wajar.
Middleware requireAuth: penjaga pintu
Di Express, otorisasi paling rapi dibuat sebagai middleware: function (req, res, next) yang dipasang sebelum handler. requireAuth membaca cookie, memverifikasi JWT, lalu menempelkan data user ke req.user. Kalau gagal, ia langsung membalas 401 dan handler tidak pernah dijalankan. Handler di belakangnya cukup memakai req.user.id dengan tenang.
Role: RBAC sederhana
RBAC (Role-Based Access Control) mengelompokkan hak akses berdasarkan peran. Di toko kita ada dua peran: user (pembeli) dan admin (pengelola toko). Middleware requireRole('admin') memeriksa req.user.role dan membalas 403 kalau tidak cocok. Karena berupa function yang mengembalikan middleware (pola "factory"), ia bisa dipakai untuk peran apa pun: requireRole('admin', 'staff').
Role ikut disimpan di payload JWT supaya tidak perlu query database setiap request. Konsekuensinya: kalau role seseorang diturunkan dari admin ke user, token lamanya masih berkata "admin" sampai kedaluwarsa. Inilah salah satu alasan token sebaiknya berumur pendek. Untuk aksi yang sangat sensitif (misalnya menghapus akun lain), kamu bisa membaca ulang role dari database.
Kepemilikan data: cek di setiap query
Role saja tidak cukup. Semua pembeli punya role user, tapi Budi hanya boleh melihat pesanan Budi. Pemeriksaan kepemilikan (ownership) dilakukan di level data, dengan dua cara:
- Filter di query:
WHERE user_id = req.user.id. Untuk daftar pesanan, ini cara paling aman karena data orang lain tidak pernah terambil sama sekali. - Ambil lalu cek: ambil pesanan berdasarkan id, lalu bandingkan
order.userIddenganreq.user.id. Dipakai untuk detail satu data, sekaligus memberi ruang bagi admin.
Prinsip yang perlu dipegang: jangan pernah percaya id pemilik dari klien. userId selalu diambil dari token (req.user.id), tidak pernah dari body atau query string. Kalau klien boleh mengirim {"userId": 7} saat membuat pesanan, Budi bisa membuat pesanan atas nama orang lain.
Urutan middleware itu penting
request → requireAuth → requireRole('admin') → validate(schema) → handler
401? 403? 400? 200/201
Autentikasi dulu, baru otorisasi, baru validasi input. Kalau urutannya terbalik (validasi dulu), tamu yang tidak login bisa mendapat pesan error validasi yang membocorkan bentuk data yang diharapkan endpoint admin.
Contoh kode
Lanjutan src/middleware/auth.js dari Modul 3. Ini bagian requireAuth dan requireRole:
// src/middleware/auth.js (lanjutan, di dalam makeAuth)
// 401: "kamu siapa?", belum login / token rusak / kedaluwarsa
async function requireAuth(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.status(401).json({ error: 'Belum login' });
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
req.user = { id: Number(payload.sub), role: payload.role };
next();
} catch {
return res.status(401).json({ error: 'Sesi tidak valid atau kedaluwarsa' });
}
}
// 403: "aku tahu kamu siapa, tapi kamu tidak boleh"
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Akses ditolak' });
}
next();
};
}
return { signToken, setAuthCookie, requireAuth, requireRole };
Opsi algorithms: ['HS256'] di jwtVerify bukan hiasan. Ia memastikan server hanya menerima token dengan algoritma yang kita pakai, sehingga penyerang tidak bisa mengirim token dengan algoritma lain yang lebih lemah.
Memakai keduanya di router produk. Daftar middleware bisa disimpan dalam array lalu di-spread:
// src/routes/products.js (potongan)
export function productsRouter({ db, auth, upload }) {
const r = Router();
const adminOnly = [auth.requireAuth, auth.requireRole('admin')];
r.get('/', /* publik, tanpa middleware */ (req, res) => { /* ... */ });
r.post('/', ...adminOnly, validate(ProductSchema), (req, res) => {
const p = db.insert(products).values(req.body).returning().get();
res.status(201).json(p);
});
r.delete('/:id', ...adminOnly, (req, res) => { /* ... */ });
return r;
}
Router pesanan: semua endpoint wajib login, daftar difilter berdasarkan pemilik, detail dicek kepemilikannya:
// src/routes/orders.js (potongan)
export function ordersRouter({ db, auth }) {
const r = Router();
r.use(auth.requireAuth); // semua endpoint di router ini wajib login
r.get('/', (req, res) => {
// admin melihat semua, user hanya miliknya sendiri
const where = req.user.role === 'admin' ? undefined : eq(orders.userId, req.user.id);
res.json(db.select().from(orders).where(where).orderBy(desc(orders.id)).all());
});
r.get('/:id', (req, res) => {
const id = Number(req.params.id);
const order = db.query.orders.findFirst({
where: eq(orders.id, id),
with: { items: { with: { product: { columns: { name: true } } } } },
}).sync();
// bukan milikmu → 404 (bukan 403) supaya tidak membocorkan bahwa pesanan itu ada
if (!order || (order.userId !== req.user.id && req.user.role !== 'admin')) {
return res.status(404).json({ error: 'Pesanan tidak ditemukan' });
}
res.json(order);
});
// POST / (checkout) memakai userId: req.user.id, BUKAN dari body
return r;
}
Hasil uji dengan curl ke server Proyek Akhir. Budi (user id 2) sudah punya pesanan nomor 1, Siti (user id 3) baru mendaftar, dan file siti.txt / admin.txt berisi cookie masing-masing:
# tanpa cookie sama sekali
curl -s -w " [%{http_code}]\n" -X POST http://localhost:3000/api/products \
-H 'content-type: application/json' -d '{"name":"Topi","price":1}'
# → {"error":"Belum login"} [401]
# cookie berisi token asal-asalan
curl -s -w " [%{http_code}]\n" -b "token=abc.def.ghi" http://localhost:3000/api/auth/me
# → {"error":"Sesi tidak valid atau kedaluwarsa"} [401]
# login sebagai user biasa, mencoba membuat produk
curl -s -w " [%{http_code}]\n" -b siti.txt -X POST http://localhost:3000/api/products \
-H 'content-type: application/json' -d '{"name":"Topi","price":1}'
# → {"error":"Akses ditolak"} [403]
# Siti mencoba membuka pesanan Budi
curl -s -w " [%{http_code}]\n" -b siti.txt http://localhost:3000/api/orders/1
# → {"error":"Pesanan tidak ditemukan"} [404]
# admin melihat semua pesanan
curl -s -w " [%{http_code}]\n" -b admin.txt http://localhost:3000/api/orders
# → [{"id":1,"userId":2,"total":150000,"status":"pending","createdAt":"2026-10-23 18:12:55"}] [200]
createdAt tercatat dalam UTC (CURRENT_TIMESTAMP SQLite), jadi 18:12 UTC = 01:12 WIB. Konversi ke zona waktu pengguna sebaiknya dilakukan di frontend.
Akun admin pertama tidak boleh dibuat lewat endpoint register (itu celah). Buat lewat skrip sekali jalan yang hanya bisa dijalankan orang yang punya akses server:
// src/seed-admin.js, node --env-file=.env src/seed-admin.js email password
import argon2 from 'argon2';
import { loadConfig } from './config.js';
import { createDb } from './db/index.js';
import { users } from './db/schema.js';
const [email, password] = process.argv.slice(2);
if (!email || !password || password.length < 8) {
console.error('Pakai: node src/seed-admin.js <email> <password-min-8>');
process.exit(1);
}
const db = createDb(loadConfig().DATABASE_FILE);
const passwordHash = await argon2.hash(password);
const u = db.insert(users).values({ email: email.toLowerCase(), name: 'Admin', passwordHash, role: 'admin' })
.onConflictDoUpdate({ target: users.email, set: { role: 'admin', passwordHash } }) // jalankan ulang = aman
.returning().get();
console.log(`Admin siap: ${u.email} (id ${u.id})`);
db.$client.close();
npm run seed:admin -- admin@toko.id admin12345
# → Admin siap: admin@toko.id (id 1)
Kesalahan umum
- Hanya memeriksa "sudah login?" tanpa memeriksa kepemilikan. Ini persis celah BOLA. Setiap endpoint yang menerima id di URL wajib bertanya: "data ini milik siapa, dan apakah orang yang meminta berhak?".
- Mengambil
userIddari body atau query. Pemilik data selalu darireq.user.idhasil verifikasi token. Input klien bisa diisi apa saja. - Menyembunyikan tombol admin di frontend lalu menganggap aman. Penyerang tidak memakai tombolmu, mereka memakai
curl. Setiap aturan akses wajib ditegakkan di server. Frontend hanya menyesuaikan tampilan. - Mencampur 401 dan 403. Frontend memakai status ini untuk memutuskan aksi: 401 berarti arahkan ke halaman login, 403 berarti tampilkan pesan "tidak punya akses". Kalau tertukar, pengguna yang sudah login dilempar ke halaman login terus-menerus.
- Lupa memasang
requireAuthsebelumrequireRole.requireRolemembacareq.user. TanparequireAuthdi depannya,req.userselaluundefined, sehingga semua orang mendapat 403, termasuk admin.
Latihan
- Mudah, Buat endpoint
GET /api/admin/usersyang hanya bisa diakses admin dan mengembalikanid,email,name,rolesemua user (tanpapasswordHash). - Sedang, Buat endpoint
PATCH /api/orders/:id/canceluntuk pembeli. Aturannya: hanya pemilik pesanan yang boleh membatalkan (bukan pemilik → 404), hanya pesanan berstatuspending(selain itu → 409), dan stok produk dikembalikan dalam satu transaksi. - Menantang, Buat middleware generik
requireOwnerOrAdmin(loadResource)yang menerima function pemuat data, mengizinkan pemilik atau admin, membalas 404 untuk selain itu, dan menyimpan hasilnya direq.resourcesupaya handler tidak perlu query ulang.
Lihat solusi
1. Pilih kolom secara eksplisit supaya hash password tidak ikut.
import { asc } from 'drizzle-orm';
import { users } from './db/schema.js';
app.get('/api/admin/users', auth.requireAuth, auth.requireRole('admin'), (req, res) => {
const rows = db.select({ id: users.id, email: users.email, name: users.name, role: users.role })
.from(users).orderBy(asc(users.id)).all();
res.json(rows);
});
// admin → [ { id: 1, email: 'admin@toko.id', name: 'Admin', role: 'admin' },
// { id: 2, email: 'budi@mail.com', name: 'Budi', role: 'user' }, ... ]
// user biasa → 403
2. Tambahkan di ordersRouter (sudah di belakang r.use(auth.requireAuth)):
r.patch('/:id/cancel', (req, res) => {
const order = db.select().from(orders).where(eq(orders.id, Number(req.params.id))).get();
if (!order || order.userId !== req.user.id) {
return res.status(404).json({ error: 'Pesanan tidak ditemukan' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: 'Pesanan yang sudah dibayar tidak bisa dibatalkan' });
}
db.transaction((tx) => {
const items = tx.select().from(orderItems).where(eq(orderItems.orderId, order.id)).all();
for (const it of items) {
tx.update(products).set({ stock: sql`${products.stock} + ${it.quantity}` })
.where(eq(products.id, it.productId)).run();
}
tx.delete(orders).where(eq(orders.id, order.id)).run(); // order_items ikut terhapus (cascade)
});
res.status(204).end();
});
// Hasil uji: stok 5 → checkout 2 → stok 3
// status 'paid' → 409 { error: 'Pesanan yang sudah dibayar tidak bisa dibatalkan' }
// Siti membatalkan pesanan Budi → 404, Budi membatalkan → 204, stok kembali 5
3. Factory middleware, mirip requireRole:
// src/middleware/owner.js
export function requireOwnerOrAdmin(loadResource) {
return (req, res, next) => {
const resource = loadResource(req);
const boleh = resource && (resource.userId === req.user.id || req.user.role === 'admin');
if (!boleh) return res.status(404).json({ error: 'Data tidak ditemukan' });
req.resource = resource; // simpan supaya handler tidak query ulang
next();
};
}
// pemakaian
app.get('/api/orders-x/:id', auth.requireAuth,
requireOwnerOrAdmin((req) => db.select().from(orders).where(eq(orders.id, Number(req.params.id))).get()),
(req, res) => res.json(req.resource));
// Siti → 404, Budi (pemilik) → 200, admin → 200
Ringkasan
- Autentikasi = "kamu siapa?", otorisasi = "kamu boleh apa?". Keduanya wajib dan berbeda.
- 401 untuk belum/gagal login, 403 untuk sudah login tapi tidak berhak, 404 untuk menyembunyikan data milik orang lain.
requireAuthmemverifikasi token dan mengisireq.user.requireRole(...roles)adalah factory middleware untuk RBAC. Urutannya: autentikasi → otorisasi → validasi → handler.- Kepemilikan dicek di level data: filter
WHERE user_id = req.user.id, atau ambil lalu bandingkan.userIdselalu dari token, tidak pernah dari input klien. - Admin pertama dibuat lewat skrip server, bukan lewat endpoint publik.