Mengamankan API dengan JWT: login, token verification, middleware authorization, dan RBAC berbasis role — membedakan autentikasi (siapa kalian) dari otorisasi (apa yang boleh kalian lakukan).

Aplikasi kita sekarang melayani siapa pun tanpa bertanya. Episode ini menutup celah itu: authentication (membuktikan identitas) dan authorization (memutuskan apa yang boleh dilakukan). Di Axum, keduanya paling nyaman dibangun sebagai middleware/extractor — bukan logika yang disisipkan di tiap handler.
Mengapa JWT menjadi pilihan utama? Karena stateless: server tidak perlu menyimpan session, token membawa klaim di dalamnya sendiri. Cocok untuk API dan microservices yang di-scale horizontal. Tapi JWT juga punya jebakan (revocation, exp), jadi kita bedah kapan memakainya dan kapan lebih baik session.
Kesalahan status code yang umum: 401 untuk autentikasi gagal, 403 untuk otorisasi ditolak. Kode di bawah menegakkan perbedaan ini.
cargo add jsonwebtoken
cargo add sha2
cargo add argon2
cargo add rand
cargo add serde --features derive| Crate | Peran |
|---|---|
jsonwebtoken | Membuat & verifikasi JWT (HS256/RS256) |
argon2 | Hashing password (bukan sha2 untuk password!) |
rand | Salt & kunci acak |
Warning
Jangan pernah menyimpan password sebagai plaintext atau hash SHA-256. Hash dengan Argon2id (pemenang Password Hashing Competition) — library argon2 di Rust. Hashing password adalah topik keamanan mendalam yang dibahas di episode 18.
Definisikan struktur JWT claims:
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: uuid::Uuid, // subject: ID user
pub role: String, // "admin" | "user"
pub exp: usize, // expiry unix timestamp
}
const JWT_SECRET: &[u8] = b"ganti-dengan-secret-panjang-dan-random";
pub fn issue_token(user_id: uuid::Uuid, role: &str) -> Result<String, jsonwebtoken::errors::Error> {
let exp = (Utc::now() + Duration::hours(24)).timestamp() as usize;
let claims = Claims {
sub: user_id,
role: role.into(),
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(JWT_SECRET),
)
}
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
decode::<Claims>(
token,
&DecodingKey::from_secret(JWT_SECRET),
&Validation::default(),
)
.map(|data| data.claims)
}Poin penting:
exp — expiry time; Validation::default() memeriksa exp otomatis. Token kedaluwarsa ditolak.sub + role — dua klaim paling dasar; sub mengidentifikasi user, role untuk otorisasi.Header::default()) cocok untuk single-service; untuk multi-service antar tim, pertimbangkan RS256 (asimetris).Password disimpan sebagai hash Argon2. Handler login membandingkan input dengan hash:
use argon2::{
password_hash::{PasswordHash, PasswordVerifier, SaltString},
Argon2,
};
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{
auth::claims::{issue_token, Claims},
error::{AppError, AppResult},
state::AppState,
};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> AppResult<Json<serde_json::Value>> {
// 1. Ambil user + hash dari DB
let user = sqlx::query!(
"SELECT id, password_hash, role FROM users WHERE username = $1",
req.username
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Unauthorized)?;
// 2. Verifikasi password dengan Argon2
let parsed = PasswordHash::new(&user.password_hash).map_err(|_| AppError::Unauthorized)?;
if Argon2::default().verify_password(req.password.as_bytes(), &parsed).is_err() {
return Err(AppError::Unauthorized);
}
// 3. Terbitkan token
let token = issue_token(user.id, &user.role)?;
Ok(Json(serde_json::json!({ "token": token })))
}Catatan keamanan yang disengaja: pesan error login identik ("Unauthorized") baik user tidak ada maupun password salah — mencegah user enumeration (menebak user mana yang terdaftar). Ini kebiasaan security yang kita pertegas di episode 18.
Sekarang lapisan yang melindungi endpoint. Middleware mengekstrak header Authorization: Bearer ..., memverifikasi, lalu menyimpan claims di extension:
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
};
use crate::{
auth::claims::verify_token,
state::AppState,
};
pub async fn require_auth(
State(_state): State<AppState>,
headers: HeaderMap,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let claims = verify_token(auth).map_err(|_| StatusCode::UNAUTHORIZED)?;
let mut request = request;
request.extensions_mut().insert(claims);
Ok(next.run(request).await)
}Handler di bawah middleware bisa membaca Claims dari extension:
use axum::extract::Extension;
pub async fn profile(Extension(claims): Extension<Claims>) -> String {
format!("Halo user {} role {}", claims.sub, claims.role)
}Extension adalah extractor (deprecated di Axum 0.8 untuk state, tapi tetap valid untuk extension request). Alternatif modern dan lebih rapi: extractor custom AuthUser — kita bangun di episode 21.
Pasang middleware ke route yang dilindungi:
use axum::{middleware, routing::get, Router};
fn protected() -> Router {
Router::new()
.route("/me", get(profile))
.route_layer(middleware::from_fn_with_state(state, require_auth))
}Autentikasi selesai; sekarang otorisasi. Middleware kedua memeriksa role dari claims:
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
use crate::auth::claims::Claims;
pub async fn require_role(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let claims = request
.extensions()
.get::<Claims>()
.ok_or(StatusCode::UNAUTHORIZED)?;
if claims.role != "admin" {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(request).await)
}Perhatikan pemisahan yang tegas:
require_auth → 401 jika token tidak valid/tidak ada.require_role → 403 jika valid tetapi role tidak cukup.Susun lapisan berurutan:
fn admin_routes() -> Router {
Router::new()
.route("/admin/users", get(list_all_users))
.layer(middleware::from_fn_with_state(state.clone(), require_role))
.layer(middleware::from_fn_with_state(state.clone(), require_auth))
}Urutan pemasangan .layer dari bawah ke atas berarti eksekusi terbalik: require_auth dijalankan lebih dulu (lapisan paling luar), baru require_role. Dengan urutan ini, require_role selalu berjalan setelah auth sukses — jadi ia bisa mengasumsikan Claims ada.
Tip
Jangan pernah menulis cek role di dalam handler secara acak. Pisahkan sebagai middleware/lapisan: handler tetap fokus pada logic bisnis, keputusan "boleh atau tidak" terpusat dan bisa diuji sekali. Pola ini juga yang mempermudah integrasi RBAC di episode 21.
| Kriteria | JWT (stateless) | Session (server-side) |
|---|---|---|
| Penyimpanan | Token di klien, klaim di token | Data di server (memory/Redis) |
| Revocation | Sulit (menunggu exp) | Mudah (hapus session) |
| Scale | Natural (stateless) | Perlu store bersama (Redis) |
| Bocornya token | Sulit diatasi sampai exp | Bisa langsung cabut |
| Cocok untuk | API/microservices | Aplikasi monolitik dengan logout instan |
JWT unggul untuk API stateless. Tapi jika kalian butuh logout instan atau menghapus akses segera (misal keamanan akun), session server-side lebih tepat. Axum tidak memihak — tower-sessions adalah crate komunitas untuk session berbasis cookie yang bisa dipasang jika kebutuhan berubah. Episode 18 membahas trade-off keamanan keduanya.
Mari rangkai alur utuh:
# 1. Login dapat token
TOKEN=$(curl -s -X POST localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"rahasia-kuat"}' \
| jq -r .token)
# 2. Akses endpoint publik - tanpa token
curl -s localhost:3000/posts
# 3. Akses /me dengan token
curl -s localhost:3000/me -H "Authorization: Bearer $TOKEN"
# 4. Akses endpoint admin tanpa role admin -> 403
curl -s -o /dev/null -w "%{http_code}\n" \
localhost:3000/admin/users -H "Authorization: Bearer $TOKEN"Status yang diharapkan: /me → 200 dengan identitas user; /admin/users → 403 jika role bukan admin; endpoint tanpa token → 401.
Pada episode 13 ini kalian telah mengamankan API:
jsonwebtoken: sub, role, exp; verifikasi otomatis expiry.require_auth (401) dan require_role (403) sebagai lapisan terpisah.Extension.Di episode 14 selanjutnya kita rapatkan input: validation & serialization — validasi body dengan validator crate, custom deserializer, dan pola serde yang aman agar data yang masuk ke aplikasi selalu bersih. Sampai jumpa di episode 14!