Hardening Auth API di Rust bukan sekadar menambahkan JWT ke endpoint login. Desain yang aman biasanya menggabungkan access token berumur pendek, refresh token yang disimpan dan divalidasi dengan benar, rotasi token, mekanisme revocation, serta pengelolaan secret yang memungkinkan key rotation tanpa memutus semua sesi pengguna.

Jika Anda membangun backend Rust, pendekatan yang paling aman untuk banyak kasus adalah: gunakan JWT hanya sebagai access token yang singkat masa berlakunya, simpan refresh token secara aman di sisi server, hash refresh token sebelum masuk database, lakukan rotasi saat refresh, dan validasi claim penting seperti iss, aud, exp, nbf, sub, serta jti. Di atas itu, tambahkan rate limit pada endpoint login dan refresh, audit log minimum, dan strategi secret rotation yang mendukung lebih dari satu key aktif.

Arsitektur yang disarankan untuk Auth API

Model yang praktis dan relatif aman adalah memisahkan peran token menjadi dua:

  • Access token (JWT): berumur pendek, misalnya hitungan menit, dikirim ke API untuk otorisasi.
  • Refresh token: berumur lebih panjang, hanya dipakai untuk meminta access token baru, disimpan aman, dan divalidasi terhadap state di server.

Alasan utamanya sederhana:

  • JWT cocok untuk verifikasi cepat tanpa query database pada setiap request.
  • Refresh token sebaiknya stateful agar bisa dicabut, dirotasi, dan diaudit.
  • Jika access token bocor, dampaknya dibatasi oleh masa berlaku yang pendek.
  • Jika refresh token bocor, deteksi dan revocation masih memungkinkan karena token tersebut tercatat di server.

Alur dasar

  1. Pengguna login dengan kredensial.
  2. Server menerbitkan access token JWT berumur pendek dan refresh token acak berentropi tinggi.
  3. Refresh token disimpan di database dalam bentuk hash, bukan plaintext.
  4. Saat refresh, client mengirim refresh token.
  5. Server mencocokkan hash token, memeriksa status, expiry, device/session metadata, lalu merotasi refresh token.
  6. Refresh token lama ditandai revoked atau replaced.

Prinsip penting: jangan gunakan refresh token sebagai JWT stateless jika kebutuhan Anda mencakup revocation, rotasi per sesi, deteksi replay, atau logout lintas device yang rapi. Itu bisa dilakukan, tetapi kompleksitas dan risiko biasanya lebih tinggi.

Desain JWT access token yang aman

Claim minimum yang perlu ada

Access token JWT sebaiknya memuat claim yang cukup untuk verifikasi dan otorisasi, tetapi tidak berlebihan. Claim yang umum dan berguna:

  • sub: identitas user atau principal.
  • iss: issuer token.
  • aud: audience yang diizinkan menerima token.
  • exp: waktu kedaluwarsa.
  • nbf: token belum boleh dipakai sebelum waktu tertentu.
  • iat: waktu terbit.
  • jti: ID unik token, berguna untuk audit atau blacklist terbatas.
  • scope atau roles: jika memang diperlukan untuk otorisasi.
  • sid: session ID, berguna mengikat token ke sesi tertentu.

Hindari memasukkan data sensitif seperti email lengkap, nomor telepon, atau informasi internal yang tidak dibutuhkan setiap request. JWT mudah dibaca siapa pun yang memegang token walaupun signature valid.

Masa berlaku yang pendek

JWT access token berumur pendek adalah kontrol utama untuk mengurangi dampak kebocoran. Umumnya target desainnya adalah beberapa menit, bukan berhari-hari. Anda tidak perlu menyimpan access token di database untuk banyak kasus, selama refresh flow Anda kuat.

Trade-off-nya:

  • Semakin pendek expiry, semakin kecil jendela penyalahgunaan.
  • Semakin pendek expiry, semakin sering endpoint refresh dipanggil.

Karena itu endpoint refresh harus dirancang aman dan efisien.

Algoritma signing dan validasi

Di Rust, crate yang umum dipakai untuk JWT adalah jsonwebtoken. Untuk algoritma, pilih yang didukung tooling Anda dan bisa dikelola dengan baik. Secara umum:

  • HMAC lebih sederhana, tetapi secret yang sama untuk sign dan verify harus dijaga ketat.
  • RSA/EC/EdDSA memisahkan private key untuk sign dan public key untuk verify, lebih nyaman untuk sistem terdistribusi.

Jika Anda belum punya kebutuhan distribusi key yang kompleks, HMAC bisa cukup. Namun untuk organisasi yang ingin memisahkan otoritas penerbitan token dari verifier, asymmetric key biasanya lebih mudah diskalakan secara operasional.

Yang lebih penting daripada jenis algoritma adalah:

  • Jangan menerima algoritma secara dinamis dari input tanpa pembatasan tegas.
  • Pin algoritma yang diharapkan saat verifikasi.
  • Validasi iss, aud, exp, dan jika dipakai, nbf.
  • Gunakan kid di header untuk mendukung key rotation.

Contoh struktur claim

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct AccessClaims {
    sub: String,
    iss: String,
    aud: String,
    exp: usize,
    nbf: usize,
    iat: usize,
    jti: String,
    sid: String,
    scope: Vec<String>,
}

Perhatikan bahwa struktur ini kecil dan fokus. Hindari menjadikan JWT sebagai pengganti profile payload.

Refresh token: simpan aman, hash sebelum disimpan, dan rotasi

Kenapa refresh token harus di-hash

Refresh token adalah kredensial. Jika database bocor dan Anda menyimpan refresh token dalam plaintext, penyerang bisa langsung menggunakannya untuk mendapatkan access token baru. Karena itu, simpan hanya hash refresh token di database.

Pola sederhananya:

  1. Generate refresh token acak dengan entropi tinggi.
  2. Kirim plaintext token hanya sekali ke client.
  3. Hash token sebelum disimpan di database.
  4. Saat refresh, hash token yang diterima lalu cocokkan dengan nilai di database.

Untuk hashing, Anda bisa memakai hash kriptografis dengan secret tambahan di sisi server, atau pendekatan password hashing tergantung kebutuhan performa dan model ancaman. Banyak sistem memilih hash cepat dengan secret server karena refresh token umumnya sudah acak dan panjang. Yang penting, jangan simpan plaintext.

Contoh struktur data sesi refresh token

use chrono::{DateTime, Utc};

#[derive(Debug, Clone)]
struct RefreshSession {
    session_id: String,
    user_id: String,
    token_hash: String,
    created_at: DateTime<Utc>,
    expires_at: DateTime<Utc>,
    revoked_at: Option<DateTime<Utc>>,
    replaced_by_session_id: Option<String>,
    user_agent: Option<String>,
    ip_address: Option<String>,
    device_id: Option<String>,
    last_used_at: Option<DateTime<Utc>>,
}

Minimalnya Anda butuh:

  • session_id
  • user_id
  • token_hash
  • expires_at
  • revoked_at

Metadata seperti IP, user agent, dan device ID membantu audit dan deteksi anomali, tetapi jangan dijadikan satu-satunya faktor keamanan karena bisa berubah atau dipalsukan.

Rotasi refresh token

Rotasi berarti setiap kali refresh berhasil, refresh token lama tidak boleh dipakai lagi. Server menerbitkan refresh token baru dan menandai token lama sebagai revoked atau replaced. Ini penting untuk mitigasi replay.

Jika token lama dipakai lagi setelah sudah dirotasi, itu sinyal kuat adanya kebocoran atau replay. Respons yang aman biasanya:

  • Cabut sesi terkait.
  • Pertimbangkan cabut seluruh chain sesi untuk user/device tersebut.
  • Audit log insiden itu.

Pseudo-code rotasi refresh token

fn refresh(refresh_token_plain: &str, now: Timestamp) -> Result<TokenPair, AuthError> {
    let token_hash = hash_refresh_token(refresh_token_plain)?;
    let session = repo.find_session_by_hash(&token_hash)?
        .ok_or(AuthError::InvalidToken)?;

    if session.revoked_at.is_some() {
        // Token sudah pernah dipakai / dicabut.
        // Ini indikasi replay jika masih muncul lagi.
        repo.revoke_session_chain(&session.session_id, now)?;
        audit.log_replay_detected(&session.user_id, &session.session_id)?;
        return Err(AuthError::ReplayDetected);
    }

    if session.expires_at <= now {
        return Err(AuthError::ExpiredToken);
    }

    let new_refresh_plain = generate_secure_random_token();
    let new_refresh_hash = hash_refresh_token(&new_refresh_plain)?;
    let new_session_id = generate_session_id();

    repo.begin_tx()?;
    repo.mark_session_replaced(&session.session_id, &new_session_id, now)?;
    repo.insert_new_session(NewSession {
        session_id: new_session_id.clone(),
        user_id: session.user_id.clone(),
        token_hash: new_refresh_hash,
        expires_at: compute_refresh_expiry(now),
    })?;
    repo.commit_tx()?;

    let access_token = issue_access_jwt(&session.user_id, &new_session_id)?;

    Ok(TokenPair {
        access_token,
        refresh_token: new_refresh_plain,
    })
}

Poin penting di sini adalah operasi update dan insert sebaiknya atomik. Kalau tidak, race condition bisa membuat dua request refresh sama-sama lolos.

Implementasi praktis di Rust tanpa terikat framework

Crate yang umum dipakai

Tanpa bergantung pada framework HTTP tertentu, kombinasi crate berikut cukup umum:

  • jsonwebtoken untuk sign/verify JWT.
  • serde untuk serialisasi claim.
  • uuid atau ID generator lain untuk jti dan session ID.
  • rand atau crate CSPRNG lain untuk refresh token acak.
  • sha2 dan/atau hmac jika Anda membuat hash refresh token dengan secret server.
  • chrono atau tipe waktu standar untuk expiry.
  • Driver database seperti sqlx atau tokio-postgres tergantung stack Anda.

Daftar ini sengaja generik. Tujuannya bukan memilih framework, tetapi menunjukkan blok bangunan utama.

Contoh pembuatan JWT dengan kid

use jsonwebtoken::{encode, Header, EncodingKey, Algorithm};

fn issue_access_jwt(claims: &AccessClaims, active_kid: &str, secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {
    let mut header = Header::new(Algorithm::HS256);
    header.kid = Some(active_kid.to_string());
    encode(&header, claims, &EncodingKey::from_secret(secret))
}

Jika memakai asymmetric key, konsepnya sama: header memuat kid, signer memakai private key, verifier memilih public key berdasarkan kid.

Contoh validasi JWT yang ketat

use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};

fn verify_access_jwt(token: &str, secret: &[u8]) -> Result<AccessClaims, AuthError> {
    let mut validation = Validation::new(Algorithm::HS256);
    validation.validate_exp = true;
    validation.set_issuer(&["auth-service"]);
    validation.set_audience(&["my-api"]);

    let data = decode::<AccessClaims>(
        token,
        &DecodingKey::from_secret(secret),
        &validation,
    ).map_err(|_| AuthError::InvalidToken)?;

    Ok(data.claims)
}

Selain validasi library, Anda masih bisa menambahkan aturan sendiri, misalnya memastikan sub tidak kosong, sid valid, atau scope sesuai endpoint.

Pseudo-code middleware auth

fn auth_middleware(req: &Request, key_store: &KeyStore) -> Result<Principal, HttpError> {
    let bearer = extract_bearer_token(req)
        .ok_or(HttpError::unauthorized())?;

    let header = parse_unverified_header(&bearer)
        .map_err(|_| HttpError::unauthorized())?;

    let kid = header.kid.as_deref()
        .ok_or(HttpError::unauthorized())?;

    let verify_key = key_store.find_verify_key(kid)
        .ok_or(HttpError::unauthorized())?;

    let claims = verify_access_jwt_with_key(&bearer, verify_key)
        .map_err(|_| HttpError::unauthorized())?;

    Ok(Principal {
        user_id: claims.sub,
        session_id: claims.sid,
        scopes: claims.scope,
    })
}

Pola ini penting untuk secret rotation: middleware tidak mengasumsikan hanya ada satu key aktif.

Secret management dan key rotation tanpa memutus semua sesi

Jangan hardcode secret

Secret JWT, secret untuk hashing refresh token, dan kredensial lain tidak boleh di-hardcode di source code atau image container. Minimal, ambil dari environment variable yang dipasang saat runtime. Lebih baik lagi, gunakan secret store terkelola sesuai platform Anda.

Prinsip praktis:

  • Development: environment variable lokal masih bisa diterima.
  • Production: gunakan secret manager atau mekanisme injeksi secret yang terkontrol.
  • Batasi siapa yang bisa membaca secret.
  • Jangan log nilai secret.

Model key set aktif dan retired

Agar key rotation tidak memutus semua sesi, simpan beberapa key sekaligus:

  • Active signing key: dipakai untuk menerbitkan token baru.
  • Retired verify keys: tidak lagi dipakai menandatangani, tetapi masih dipakai memverifikasi token lama sampai seluruh access token yang diterbitkan dengan key itu habis masa berlakunya.

Untuk refresh token, pendekatannya bergantung desain Anda. Jika hashing refresh token bergantung pada secret global, Anda juga perlu mendukung verifikasi dengan secret lama selama masa transisi, atau melakukan rehash saat token dipakai berikutnya.

Skema key rotation sederhana

  1. Buat key baru dengan kid baru.
  2. Tandai key baru sebagai active signing key.
  3. Simpan key lama sebagai verify-only.
  4. Biarkan verifier menerima key lama sampai semua access token lama kedaluwarsa.
  5. Setelah masa aman lewat, hapus key lama dari verifier.

Karena access token berumur pendek, jendela transisi biasanya tidak lama. Inilah salah satu alasan access token pendek mempermudah operasi keamanan.

Contoh struktur key store

struct SigningKey {
    kid: String,
    material: Vec<u8>,
    status: KeyStatus,
}

enum KeyStatus {
    Active,
    VerifyOnly,
    Retired,
}

struct KeyStore {
    keys: Vec<SigningKey>,
}

impl KeyStore {
    fn active_signing_key(&self) -> Option<&SigningKey> {
        self.keys.iter().find(|k| matches!(k.status, KeyStatus::Active))
    }

    fn find_verify_key(&self, kid: &str) -> Option<&SigningKey> {
        self.keys.iter().find(|k| k.kid == kid && !matches!(k.status, KeyStatus::Retired))
    }
}

Pada implementasi nyata, key store bisa di-refresh dari secret manager, file terenkripsi, atau konfigurasi runtime. Pastikan reload dilakukan aman dan konsisten antar instance bila service Anda berjalan lebih dari satu replika.

Mitigasi replay, revocation, dan logout

Replay pada refresh endpoint

Replay paling sering relevan pada refresh token. Jika refresh token lama dipakai dua kali karena pencurian atau race condition client, sistem harus bisa mendeteksinya. Itulah gunanya rotasi token dan status sesi.

Praktik yang baik:

  • Setiap refresh token hanya valid satu kali.
  • Simpan chain penggantian token.
  • Jika token yang sudah revoked muncul lagi, anggap insiden keamanan.
  • Revoke seluruh sesi terkait jika perlu.

Revocation access token

Karena access token berumur pendek, banyak sistem memilih tidak menyimpan blacklist access token global. Sebagai gantinya, mereka mengandalkan:

  • masa berlaku yang singkat, dan
  • pemblokiran refresh token atau session ID.

Jika Anda perlu revocation instan untuk access token, Anda harus menambah state, misalnya blacklist berdasarkan jti atau session version. Trade-off-nya adalah kompleksitas dan biaya lookup pada request. Untuk banyak API internal dan consumer API umum, expiry singkat plus revocation sesi sudah cukup baik.

Logout yang benar

Logout bukan berarti “hapus token di client” saja. Pada sisi server, minimal:

  • revoke refresh session saat logout device saat ini, atau
  • revoke semua sesi user untuk logout semua device.

Jika access token masih berlaku beberapa menit setelah logout, itu konsekuensi desain JWT stateless. Biasanya diterima jika expiry access token pendek.

Rate limiting, audit log, dan observability minimum

Rate limit endpoint yang sensitif

Endpoint login dan refresh adalah target utama brute force dan abuse. Tambahkan rate limit pada:

  • POST /login
  • POST /refresh
  • POST /password/reset jika ada

Pendekatan yang umum:

  • per IP address,
  • per user identifier seperti email atau username,
  • kombinasi keduanya untuk mengurangi false positive.

Jangan hanya mengandalkan rate limit per IP, karena NAT dan proxy bisa membuat banyak user sah berbagi alamat yang sama. Sebaliknya, jangan hanya per user identifier karena penyerang bisa menyebar serangan ke banyak akun.

Audit log minimum

Audit log tidak harus rumit, tetapi harus cukup untuk investigasi. Minimal catat event berikut:

  • login berhasil dan gagal,
  • refresh berhasil,
  • refresh ditolak karena token invalid/expired/revoked,
  • deteksi replay refresh token,
  • logout satu sesi atau semua sesi,
  • rotasi secret/key administratif.

Field yang berguna:

  • timestamp,
  • user_id jika tersedia,
  • session_id,
  • IP address,
  • user agent ringkas,
  • hasil event,
  • reason code, bukan hanya pesan bebas.

Jangan log token penuh, secret, password, atau header Authorization utuh. Jika perlu korelasi, log sebagian kecil identifier yang aman atau hash yang tidak bisa dipakai ulang.

Monitoring yang berguna

Selain audit log, pantau metrik sederhana:

  • jumlah login gagal,
  • jumlah refresh per user/per device,
  • jumlah invalid signature,
  • jumlah replay terdeteksi,
  • latensi endpoint auth.

Lonjakan tiba-tiba pada metrik ini sering menjadi indikator masalah implementasi atau serangan aktif.

Contoh alur implementasi end-to-end

1. Login

  1. Validasi kredensial user.
  2. Generate session_id dan jti.
  3. Buat access JWT dengan expiry pendek dan kid aktif.
  4. Generate refresh token acak.
  5. Hash refresh token dan simpan sebagai sesi baru di database.
  6. Kirim access token dan refresh token ke client melalui kanal yang aman.

Jika aplikasi berbasis browser, pertimbangkan cookie yang tepat untuk refresh token agar tidak terekspos ke JavaScript. Detail cookie bergantung arsitektur Anda, tetapi prinsipnya adalah membatasi permukaan serangan XSS dan CSRF sesuai model aplikasi.

2. Akses resource

  1. Client mengirim access token sebagai Bearer token.
  2. Middleware membaca header, menemukan kid, dan memilih verify key.
  3. Server memverifikasi signature dan claim wajib.
  4. Jika valid, request dilanjutkan dengan principal yang telah diparse.

3. Refresh token

  1. Client mengirim refresh token.
  2. Server hash token lalu lookup sesi.
  3. Periksa expiry, revoked state, dan aturan tambahan bila ada.
  4. Dalam transaksi atomik, revoke sesi lama dan buat sesi baru.
  5. Terbitkan access token baru dan refresh token baru.

4. Logout

  1. Client memanggil endpoint logout.
  2. Server menandai refresh session sebagai revoked.
  3. Client menghapus access token lokal.

Checklist keamanan untuk hardening auth API

  • Access token JWT berumur pendek.
  • Refresh token acak, panjang, dan hanya dipakai untuk refresh.
  • Refresh token disimpan dalam bentuk hash, bukan plaintext.
  • Rotasi refresh token setiap kali dipakai.
  • Deteksi replay dan revoke chain sesi jika perlu.
  • Validasi iss, aud, exp, dan algoritma JWT secara ketat.
  • Gunakan kid untuk key rotation.
  • Simpan secret di environment/secret store, bukan di source code.
  • Dukung lebih dari satu verify key saat rotasi.
  • Rate limit login dan refresh endpoint.
  • Audit log event auth penting tanpa membocorkan token.
  • Gunakan transaksi atau mekanisme atomik pada rotasi refresh token.
  • Uji race condition pada refresh.
  • Tetapkan kebijakan logout dan revocation yang jelas.

Kesalahan umum yang harus dihindari

1. Menyimpan refresh token plaintext

Ini salah satu kesalahan paling berbahaya. Kebocoran database langsung menjadi pengambilalihan sesi.

2. Access token terlalu panjang umurnya

JWT yang berlaku berjam-jam atau berhari-hari memperbesar dampak kebocoran dan menyulitkan revocation.

3. Tidak memvalidasi claim penting

Signature valid saja tidak cukup. Tanpa validasi issuer, audience, expiry, dan algoritma yang tepat, token yang tidak seharusnya diterima bisa lolos.

4. Satu secret untuk semuanya tanpa rotasi

Mengandalkan satu secret statis bertahun-tahun adalah risiko operasional. Desain sejak awal agar rotasi memungkinkan tanpa downtime besar.

5. Tidak menangani race condition saat refresh

Dua request refresh bersamaan dari client atau penyerang bisa membuat state sesi kacau jika update tidak atomik.

6. Melog token ke log aplikasi

Authorization header, refresh token, dan secret tidak boleh muncul di log biasa, tracing, atau error dump.

7. Menganggap JWT selalu stateless lebih baik

Untuk access token, stateless sering menguntungkan. Untuk refresh token, stateful biasanya jauh lebih aman dan operasionalnya lebih masuk akal.

Debugging dan pengujian yang sebaiknya dilakukan

Uji kasus normal dan kasus gagal

  • JWT valid diterima.
  • JWT dengan aud salah ditolak.
  • JWT expired ditolak.
  • JWT dengan kid lama masih diterima selama masa transisi rotation.
  • Refresh token valid menghasilkan token pair baru.
  • Refresh token lama ditolak setelah rotasi.
  • Replay refresh token memicu revoke chain atau alarm sesuai kebijakan.

Uji konkurensi

Simulasikan dua request refresh bersamaan untuk token yang sama. Hasil yang diharapkan: hanya satu yang berhasil, atau keduanya tidak menghasilkan sesi ganda yang valid. Ini penting untuk memastikan transaksi database dan constraint Anda benar.

Uji rotasi key

Pastikan service bisa:

  • menerbitkan token dengan key baru,
  • tetap memverifikasi token lama selama masa transisi,
  • gagal memverifikasi token lama setelah key benar-benar dipensiunkan.

Penutup

Hardening Auth API di Rust yang realistis tidak berhenti di pembuatan JWT. Desain yang aman biasanya memadukan access token JWT berumur pendek dengan refresh token stateful yang di-hash, dirotasi, dan bisa dicabut. Tambahkan validasi claim yang ketat, secret management yang benar, key rotation berbasis kid, mitigasi replay, rate limit, dan audit log minimum.

Jika Anda ingin mulai dari fondasi yang sehat, prioritaskan urutan ini: pendekkan umur access token, hash refresh token di database, terapkan rotasi refresh token secara atomik, validasi JWT dengan ketat, lalu bangun key rotation dan observability. Dengan urutan itu, Auth API Rust Anda akan jauh lebih tahan terhadap kebocoran token, replay, dan kesalahan operasional yang sering terjadi di produksi.