TOTP (Time-based One-Time Password, standar RFC 6238) adalah kode 6 digit yang berganti tiap 30 detik, dipakai sebagai faktor kedua setelah password. Server dan aplikasi authenticator di HP (Google Authenticator, Authy, 1Password) menghitung kode yang sama masing-masing, tanpa saling mengirim apa pun. Bayangin dua orang yang memegang buku sandi yang sama persis, satu halaman untuk tiap setengah menit. Jam menunjukkan halaman mana yang dipakai sekarang, jadi keduanya membuka halaman yang sama dan membaca angka yang sama. Buku sandinya adalah secret, dan nomor halamannya dihitung dari jam.
🧪 Tebakan cepat: jam server Portal Sekolah menunjukkan 07.00.10, tapi jam HP Siti telat 20 detik, jadi HP-nya masih di 06.59.50. Kode di HP Siti diterima server atau ditolak? Tebak dulu, jawabannya ada di bagian "Verifikasi: jendela ±1 dan kode sekali pakai".
Menyalakan 2FA: secret dibagi sekali
Siti menyalakan 2FA di halaman profil Portal Sekolah. Yang terjadi:
- Server membuat secret: 20 byte acak dari
crypto.getRandomValues(160 bit), lalu menuliskannya dalam base32, huruf A sampai Z dan angka 2 sampai 7, supaya bisa diketik manusia. - Server menampilkan secret itu sebagai URI
otpauth://, biasanya digambar jadi QR code. Siti memindainya dengan aplikasi authenticator, dan aplikasinya menyimpan secret itu. - Server meminta satu kode dari aplikasi. Kalau cocok, 2FA baru dinyalakan. Ini memastikan Siti benar-benar sudah menyimpan secret-nya.
Lesson dan Lab ini memakai secret uji dari RFC 6238, teks 12345678901234567890, supaya hasilnya bisa kamu cek sendiri. URI-nya:
otpauth://totp/Portal%20Sekolah:siti%40sekolah.test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Portal%20Sekolah&algorithm=SHA1&digits=6&period=30
totpjenisnya,Portal Sekolah:[email protected]label yang tampil di aplikasi (karakter khususnya di-encode jadi%20dan%40).secretdalam base32,issuernama aplikasinya, danalgorithm,digits,periodnilai standarnya: SHA1, 6 digit, 30 detik.
Secret ini dibagi sekali saja, saat setup. Server menyimpannya terenkripsi, karena siapa pun yang memegang secret bisa membuat semua kode Siti.
Dari secret dan jam ke enam digit
Di HP maupun di server, rumusnya sama:
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32Decode(text) {
let bits = 0, value = 0;
const bytes = [];
for (const ch of text.replace(/=+$/, "").toUpperCase()) {
const idx = ALPHABET.indexOf(ch);
if (idx < 0) throw new Error("bukan huruf base32: " + ch);
value = (value << 5) | idx; // tiap huruf membawa 5 bit
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff); // keluarkan 1 byte utuh
bits -= 8;
}
}
return new Uint8Array(bytes);
}
async function totp(secretBase32, unixSeconds, digits = 6) {
const counter = Math.floor(unixSeconds / 30); // nomor langkah 30 detik
const msg = new Uint8Array(8); // counter sebagai 8 byte, big-endian
for (let i = 7, c = counter; i >= 0; i--, c = Math.floor(c / 256)) msg[i] = c & 0xff;
const key = await crypto.subtle.importKey("raw", base32Decode(secretBase32), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, msg)); // 20 byte
const offset = mac[19] & 0x0f; // dynamic truncation
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
Math.floor(unixSeconds / 30)adalah counter: berapa langkah 30 detik sudah lewat sejak 1 Januari 1970. Selama 30 detik counter-nya sama, jadi kodenya juga sama. Satuannya detik;Date.now()di JavaScript dalam milidetik, jadi bagi dulu dengan 1000.- Counter ditulis sebagai 8 byte, byte terbesar di depan (big-endian), karena itu pesan yang ditandatangani HMAC.
crypto.subtle.sign("HMAC", ...)denganSHA-1menghasilkan HMAC-SHA1: 20 byte yang cuma bisa dibuat oleh yang memegang secret.- Dynamic truncation: 4 bit terakhir dari byte ke-20 menentukan
offset, lalu 4 byte mulai dari situ diambil.& 0x7fmembuang bit teratas supaya angkanya selalu positif. % 10 ** digitsmenyisakan 6 digit terakhir, danpadStartmenambahkan nol di depan. Kode042891harus tetap 6 karakter, jadi kodenya string, bukan angka.
Cek hasilnya dengan vektor uji RFC 6238
RFC 6238 memuat tabel uji (Appendix B): secret 12345678901234567890, waktu T = 59, 8 digit, harus menghasilkan 94287082. Ini jejak fungsi totp di atas, persis yang dihitung Lab:
| Langkah | Nilai |
|---|---|
| counter | Math.floor(59 / 30) = 1 |
| 8 byte counter | 00 00 00 00 00 00 00 01 |
| HMAC-SHA1 | 75a48a19d4cbe100644e8ac1397eea747a2d33ab |
| offset | byte terakhir ab, 0xab & 0x0f = 11 |
| 4 byte mulai byte ke-11 | c1 39 7e ea |
| bit teratas dibuang | 0x41397eea = 1.094.287.082 |
% 10 ** 8 |
94287082, sama dengan RFC |
% 10 ** 6 |
287082 |
Kalau implementasimu menghasilkan 94287082 untuk input ini, berarti benar. Dengan secret yang sama, Jumat 9 Oktober 2026 pukul 07.00.00 WIB (Unix 1791504000) jadi counter 59716800 dan kode 911338.
Verifikasi: jendela ±1 dan kode sekali pakai
Server gak menerima kode yang dihitung untuk jamnya sendiri saja. Jam HP jarang persis sama dengan jam server, selisihnya disebut clock drift. Jadi server mencoba tiga counter: satu langkah sebelumnya, sekarang, dan satu sesudahnya.
async function verifyTotp(user, code, unixSeconds) {
const now = Math.floor(unixSeconds / 30);
for (const step of [-1, 0, 1]) { // toleransi jam yang meleset
const counter = now + step;
if ((await totp(user.secret, counter * 30)) !== code) continue;
if (counter <= user.lastUsedCounter) return "sudah dipakai"; // kode sekali pakai
user.lastUsedCounter = counter; // simpan di database
return "diterima";
}
return "salah";
}
Di tebakan tadi, server di 07.00.10 berada di counter 59716800 (kode 911338). HP Siti di 06.59.50 masih di counter 59716799 dan menampilkan 839888. Itu cocok dengan langkah -1, jadi kodenya diterima. Itu jawaban tebakannya. Kalau jam HP telat lebih dari satu langkah, kodenya ditolak.
Kenapa cuma ±1? Tiap langkah tambahan berarti lebih banyak kode yang sah sekaligus, jadi menebak kode jadi lebih mudah. Satu langkah ke tiap arah adalah saran RFC 6238.
Baris lastUsedCounter mencegah replay: kode yang sama dikirim ulang, misalnya karena tombol ditekan dua kali atau ada yang sempat melihat layar HP. Selama jendela 90 detik itu kodenya masih cocok, jadi tanpa catatan ini kode yang sama bisa dipakai lagi. Server menyimpan counter terakhir yang diterima, dan menolak counter yang sama atau lebih lama.
Alur login dengan 2FA
POST /login {email, password} password cocok, 2FA aktif
-> 200 {"two_factor": true} belum login, sesi cuma mencatat "menunggu kode"
POST /two-factor-challenge {code} verifyTotp: "diterima"
-> 302 Location: /dashboard baru sekarang login, session ID di-regenerate
Batasi juga percobaan kode, misalnya 5 kali per menit, karena 6 digit cuma punya sejuta kemungkinan. Caranya dibahas di lesson Rate Limiting untuk Autentikasi. Kenapa faktor kedua penting, dibahas di lesson Multi-factor Authentication (MFA).
Latihan: HMAC-SHA1 tanpa await
crypto.subtle.sign selalu async, sedangkan latihan di bawah membaca console.log langsung. Jadi kode awalnya sudah membawa hmacSha1(kunci, pesan): SHA-1 dan HMAC yang ditulis di JavaScript biasa. Ia menerima dua array byte dan mengembalikan 20 byte yang sama persis dengan hasil crypto.subtle.sign. Kamu menulis sisanya, langkah yang sama dengan fungsi totp di atas:
function generateTotp(secret, timestamp) {
const counter = Math.floor(timestamp / 30);
const pesan = [];
for (let i = 7; i >= 0; i--) pesan.push(Math.floor(counter / 2 ** (8 * i)) & 0xff); // 8 byte, big-endian
const mac = hmacSha1(secret, pesan); // 20 byte
// ...lalu dynamic truncation dan % 1000000, sama seperti totp()
}
secret di latihan sudah berupa byte (secret uji RFC, teks 12345678901234567890), jadi gak perlu base32Decode. Hasilnya dicek dengan vektor uji RFC 6238 dalam 6 digit: T = 59 jadi 287082, dan T = 1234567890 jadi 005924. verifyTotp(secret, code, timestamp) mencoba timestamp - 30, timestamp, dan timestamp + 30, lalu mengembalikan true kalau salah satunya sama dengan code.
💡 Recovery code dan Laravel Fortify
HP bisa hilang. Saat 2FA dinyalakan, beri 8 sampai 10 recovery code sekali pakai, simpan hash-nya, dan coret yang sudah dipakai. Di Laravel, paket resmi Fortify menyediakan semua ini: kolom two_factor_secret (terenkripsi), two_factor_recovery_codes, dan two_factor_confirmed_at di tabel users, route POST /two-factor-challenge, dan pengecekan yang menolak kode yang sama dipakai dua kali.
⚠️ Jebakan yang sering ketemu
- Milidetik dipakai sebagai detik.
Math.floor(Date.now() / 30)menghasilkan counter yang salah total. Bagi dengan 1000 dulu. - Kode disimpan sebagai angka.
42891cuma 5 digit. Kode042891harus string, pakaipadStart(6, "0"). - Jendela terlalu lebar. Menerima ±10 langkah memang jarang gagal, tapi 21 kode sah sekaligus. Cukup ±1.
- Tanpa cek kode yang sudah dipakai. Kode yang sama bisa dipakai lagi selama masih di jendela. Simpan counter terakhir yang diterima.
- Secret dikirim lewat email atau disimpan apa adanya. Tampilkan sekali di halaman setup, simpan terenkripsi.
TL;DR: TOTP (RFC 6238) = HMAC-SHA1(secret, counter), counter = Math.floor(unixDetik / 30) sebagai 8 byte big-endian, lalu dynamic truncation (offset dari 4 bit terakhir, 4 byte, buang bit teratas) dan % 10 ** 6, dengan nol di depan. Secret dibagi sekali lewat URI otpauth:// (QR), dalam base32. Server mencoba counter -1, 0, +1 untuk clock drift, dan menyimpan counter terakhir yang diterima supaya kode yang sama gak bisa dipakai dua kali. Cek implementasimu: secret 12345678901234567890, T = 59, 8 digit, harus 94287082.