Session menyimpan status login di server, dan biayanya terasa saat aplikasi tumbuh: setiap permintaan harus mengecek penyimpanan, dan penyimpanan itu harus dibagi antar instance. Token-based authentication membalik arah: server menerbitkan token bertanda tangan, klien menyimpannya, dan server cukup memverifikasi tanda tangan tanpa lookup apa pun. Chapter ini merakit token bergaya JWT dari bahan standard library — HMAC untuk tanda tangan, base64 URL untuk penyandian, dan klaim JSON sebagai muatan — lalu menguji ketangguhannya terhadap pemalsuan.
Verifikasi Tanpa Penyimpanan #
Alur token hanya punya dua tahap. Saat login, server menghasilkan token berisi klaim — siapa pemiliknya dan sampai kapan berlaku — lalu menandatanganinya dengan kunci rahasia. Pada setiap permintaan berikutnya, klien melampirkan token dan server menghitung ulang tanda tangan: hasil yang cocok berarti token memang keluaran server dan isinya tidak diubah. Konsekuensi praktisnya, token tidak dapat dicabut satu per satu tanpa mekanisme tambahan sehingga umurnya dibuat pendek. Tabel berikut membandingkan kedua pendekatan:
| Aspek | Session | Token |
|---|---|---|
| Lokasi status | penyimpanan server | klaim di dalam token |
| Verifikasi | lookup ke penyimpanan | hitung ulang signature |
| Pencabutan langsung | mudah — hapus entri | sulit — andalkan umur pendek |
| Banyak instance | butuh penyimpanan bersama | setiap instance verifikasi sendiri |
Menandatangani Data dengan HMAC #
HMAC (hash-based message authentication code) menggabungkan kunci rahasia dengan data. Fungsi hmac.New(sha256.New, kunci) membuat penanda, Write mengisi datanya, dan Sum(nil) menghasilkan 32 byte signature. Penyerang yang tidak memegang kunci tidak bisa menghasilkan signature yang sama, dan perubahan satu byte data mengubah seluruh hasil. Perbandingan dua signature memakai hmac.Equal, bukan bytes.Equal, karena hmac.Equal bekerja dengan waktu konstan sehingga durasi eksekusi tidak membocorkan isi perbandingan.
Struktur header.payload.signature #
JWT (JSON Web Token) membakukan format token bertanda tangan menjadi tiga segmen terpisah titik: header menyatakan algoritma, payload menyimpan klaim, dan signature melindungi keduanya. Segmen disandikan dengan base64.RawURLEncoding — varian URL-safe tanpa padding — agar token amat ditaruh di header HTTP. Klaim standar yang dipakai contoh ini:
| Klaim | Arti | Nilai pada contoh |
|---|---|---|
sub | subjek — pemilik token | "budi" |
iat | issued at — waktu terbit (detik Unix) | 1768464000 |
exp | expiration — batas berlaku (detik Unix) | 1768467600 |
Program: Token HMAC Manual #
Program token-demo.go merakit token bergaya JWT dengan kunci tetap dan waktu beku agar output deterministik, lalu menguji tiga kasus: token sah, token yang payload-nya dipalsukan, dan token kedaluwarsa.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
)
// kunciDemo dibuat tetap agar output contoh deterministik; produksi memuat
// kunci acak dari environment variable dan tidak menuliskannya di source code.
const kunciDemo = "kunci-demo-tanda-tangan"
// waktuSekarang dibekukan agar contoh deterministik; produksi memakai time.Now().
var waktuSekarang = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)
// klaim berisi data yang dibawa token. Hanya klaim standar yang dipakai di sini.
type klaim struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
// tandatangan menghitung HMAC-SHA256 lalu menyandikannya ke base64 URL.
func tandatangan(data string) string {
mac := hmac.New(sha256.New, []byte(kunciDemo))
mac.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// buatToken merakit header.payload.signature seperti struktur JWT.
func buatToken(sub string, iat, exp int64) (string, error) {
header := `{"alg":"HS256","typ":"JWT"}`
payload, err := json.Marshal(klaim{Sub: sub, Iat: iat, Exp: exp})
if err != nil {
return "", err
}
h := base64.RawURLEncoding.EncodeToString([]byte(header))
p := base64.RawURLEncoding.EncodeToString(payload)
return h + "." + p + "." + tandatangan(h+"."+p), nil
}
// verifikasiToken memeriksa signature lalu kedaluwarsa token.
func verifikasiToken(token string) (klaim, error) {
bagian := strings.Split(token, ".")
if len(bagian) != 3 {
return klaim{}, errors.New("token harus terdiri dari tiga bagian")
}
sigHitung, err := base64.RawURLEncoding.DecodeString(tandatangan(bagian[0] + "." + bagian[1]))
if err != nil {
return klaim{}, errors.New("signature tidak valid")
}
sigAsli, err := base64.RawURLEncoding.DecodeString(bagian[2])
if err != nil {
return klaim{}, errors.New("signature tidak valid")
}
// hmac.Equal membandingkan byte demi byte dengan waktu konstan.
if !hmac.Equal(sigAsli, sigHitung) {
return klaim{}, errors.New("signature tidak cocok")
}
isi, err := base64.RawURLEncoding.DecodeString(bagian[1])
if err != nil {
return klaim{}, errors.New("payload tidak valid")
}
var k klaim
if err := json.Unmarshal(isi, &k); err != nil {
return klaim{}, errors.New("payload bukan JSON valid")
}
if waktuSekarang.Unix() >= k.Exp {
return klaim{}, errors.New("token sudah kedaluwarsa")
}
return k, nil
}
func main() {
// Token sah untuk budi, berlaku satu jam sejak waktu beku.
token, err := buatToken("budi", waktuSekarang.Unix(), waktuSekarang.Add(time.Hour).Unix())
if err != nil {
log.Fatal(err)
}
fmt.Println("token :", token)
bagian := strings.Split(token, ".")
isiPayload, err := base64.RawURLEncoding.DecodeString(bagian[1])
if err != nil {
log.Fatal(err)
}
fmt.Println("payload :", string(isiPayload))
k, err := verifikasiToken(token)
if err != nil {
log.Fatal(err)
}
fmt.Println("token sah : valid (sub:", k.Sub+")")
// Pemalsuan: payload diganti menjadi sub=siti, signature lama dipakai ulang.
payloadPalsu, _ := json.Marshal(klaim{Sub: "siti", Iat: waktuSekarang.Unix(), Exp: waktuSekarang.Add(time.Hour).Unix()})
bagian[1] = base64.RawURLEncoding.EncodeToString(payloadPalsu)
_, err = verifikasiToken(strings.Join(bagian, "."))
fmt.Println("token palsu : tidak valid -", err)
// Kedaluwarsa: token diterbitkan dua jam lalu dan sudah lewat masa berlaku.
tokenLama, err := buatToken("budi", waktuSekarang.Add(-2*time.Hour).Unix(), waktuSekarang.Add(-time.Hour).Unix())
if err != nil {
log.Fatal(err)
}
_, err = verifikasiToken(tokenLama)
fmt.Println("token lama : tidak valid -", err)
}Output yang Dihasilkan:
token : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJidWRpIiwiaWF0IjoxNzY4NDY0MDAwLCJleHAiOjE3Njg0Njc2MDB9.gbYji0nT6UDket7KpXMyVk2xq6vv3qyCK6uv_z8wyYQ
payload : {"sub":"budi","iat":1768464000,"exp":1768467600}
token sah : valid (sub: budi)
token palsu : tidak valid - signature tidak cocok
token lama : tidak valid - token sudah kedaluwarsaMari kita telaah baris demi baris program di atas:
- Baris 17 dan 20: kunci dan waktu dibuat tetap supaya output contoh dapat diprediksi; produksi memuat kunci dari environment variable dan memakai
time.Now(). - Baris 23-27: struct
klaimmemakai struct tagjsonagar field tampil sebagai klaim standarsub,iat, danexp. - Baris 29-34:
tandatanganmembuat HMAC-SHA256 atas data, lalu menyandikan hasilnya ke base64 URL tanpa padding. - Baris 36-46:
buatTokenmenulis header literal, memarshal klaim ke JSON, menyandikan keduanya, lalu menandatangani gabunganheader.payload. - Baris 48-78:
verifikasiTokenmemecah token menjadi tiga bagian, menghitung ulang signature atas header dan payload yang diterima, membandingkannya denganhmac.Equal(baris 63), lalu memeriksa kedaluwarsa (baris 74) — urutan penting: signature lebih dulu, klaim kemudian. - Baris 86-92: baris pertama mencetak token utuh, baris kedua mendekode payload tanpa kunci — bukti bahwa token bersifat terbaca, bukan terenkripsi.
- Baris 100-104: payload diganti menjadi
sub: "siti"dengan signature lama; signature yang dihitung ulang tidak cocok sehingga pemalsuan gagal. - Baris 106-112: token dengan
expdi masa lalu lolos pemeriksaan signature tetapi ditolak oleh pemeriksaan kedaluwarsa.
HS256 versus RS256 #
Contoh di atas memakai HS256 — HMAC dengan SHA-256 — yang memakai satu kunci rahasia untuk menandatangani sekaligus memverifikasi. RS256 memakai RSA: kunci privat menandatangani, kunci publik memverifikasi, sehingga layanan lain dapat memeriksa token tanpa menerima kemampuan menandatangani. HS256 pas bila penerbit sekaligus verifikator; RS256 pas saat banyak layanan saling memverifikasi token yang sama. Untuk produksi, package github.com/golang-jwt/jwt sudah menangani format, pilihan algoritma, dan pemeriksaan kedaluwarsa — perakit manual pada chapter ini bertujuan membuka isi kotak hitamnya.
Menyimpan Token di Klien #
Token di sisi klien tersimpan di salah satu dari dua tempat dengan risiko berbeda. localStorage dapat dibaca JavaScript, sehingga satu celah XSS langsung membocorkan token. Cookie HttpOnly terlindung dari JavaScript, tetapi terkirim otomatis pada setiap permintaan dan membuka pintu CSRF — yang ditutup kembali dengan token CSRF atau SameSite=Strict pada chapter CSRF.
| Penyimpanan | Risiko utama | Mitigasi |
|---|---|---|
localStorage | XSS membaca token kapan pun | sanitasi output, Content-Security-Policy |
Cookie HttpOnly + SameSite | CSRF memakai cookie otomatis | token CSRF, SameSite=Strict |
Token pada chapter ini diterbitkan oleh server aplikasi Anda sendiri. Saat pengguna login memakai akun Google atau GitHub, penerbitnya justru pihak ketiga — alur penerbitan seperti itu bernama OAuth2 dan menjadi topik chapter berikutnya.
Referensi #
- Package crypto/hmac — HMAC dan perbandingan waktu-konstan.
- Package crypto/sha256 — fungsi hash SHA-256.
- Package encoding/base64 — varian RawURLEncoding.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
token-demo.go
Unduh berkaspackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
)
// kunciDemo dibuat tetap agar output contoh deterministik; produksi memuat
// kunci acak dari environment variable dan tidak menuliskannya di source code.
const kunciDemo = "kunci-demo-tanda-tangan"
// waktuSekarang dibekukan agar contoh deterministik; produksi memakai time.Now().
var waktuSekarang = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)
// klaim berisi data yang dibawa token. Hanya klaim standar yang dipakai di sini.
type klaim struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
// tandatangan menghitung HMAC-SHA256 lalu menyandikannya ke base64 URL.
func tandatangan(data string) string {
mac := hmac.New(sha256.New, []byte(kunciDemo))
mac.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// buatToken merakit header.payload.signature seperti struktur JWT.
func buatToken(sub string, iat, exp int64) (string, error) {
header := `{"alg":"HS256","typ":"JWT"}`
payload, err := json.Marshal(klaim{Sub: sub, Iat: iat, Exp: exp})
if err != nil {
return "", err
}
h := base64.RawURLEncoding.EncodeToString([]byte(header))
p := base64.RawURLEncoding.EncodeToString(payload)
return h + "." + p + "." + tandatangan(h+"."+p), nil
}
// verifikasiToken memeriksa signature lalu kedaluwarsa token.
func verifikasiToken(token string) (klaim, error) {
bagian := strings.Split(token, ".")
if len(bagian) != 3 {
return klaim{}, errors.New("token harus terdiri dari tiga bagian")
}
sigHitung, err := base64.RawURLEncoding.DecodeString(tandatangan(bagian[0] + "." + bagian[1]))
if err != nil {
return klaim{}, errors.New("signature tidak valid")
}
sigAsli, err := base64.RawURLEncoding.DecodeString(bagian[2])
if err != nil {
return klaim{}, errors.New("signature tidak valid")
}
// hmac.Equal membandingkan byte demi byte dengan waktu konstan.
if !hmac.Equal(sigAsli, sigHitung) {
return klaim{}, errors.New("signature tidak cocok")
}
isi, err := base64.RawURLEncoding.DecodeString(bagian[1])
if err != nil {
return klaim{}, errors.New("payload tidak valid")
}
var k klaim
if err := json.Unmarshal(isi, &k); err != nil {
return klaim{}, errors.New("payload bukan JSON valid")
}
if waktuSekarang.Unix() >= k.Exp {
return klaim{}, errors.New("token sudah kedaluwarsa")
}
return k, nil
}
func main() {
// Token sah untuk budi, berlaku satu jam sejak waktu beku.
token, err := buatToken("budi", waktuSekarang.Unix(), waktuSekarang.Add(time.Hour).Unix())
if err != nil {
log.Fatal(err)
}
fmt.Println("token :", token)
bagian := strings.Split(token, ".")
isiPayload, err := base64.RawURLEncoding.DecodeString(bagian[1])
if err != nil {
log.Fatal(err)
}
fmt.Println("payload :", string(isiPayload))
k, err := verifikasiToken(token)
if err != nil {
log.Fatal(err)
}
fmt.Println("token sah : valid (sub:", k.Sub+")")
// Pemalsuan: payload diganti menjadi sub=siti, signature lama dipakai ulang.
payloadPalsu, _ := json.Marshal(klaim{Sub: "siti", Iat: waktuSekarang.Unix(), Exp: waktuSekarang.Add(time.Hour).Unix()})
bagian[1] = base64.RawURLEncoding.EncodeToString(payloadPalsu)
_, err = verifikasiToken(strings.Join(bagian, "."))
fmt.Println("token palsu : tidak valid -", err)
// Kedaluwarsa: token diterbitkan dua jam lalu dan sudah lewat masa berlaku.
tokenLama, err := buatToken("budi", waktuSekarang.Add(-2*time.Hour).Unix(), waktuSekarang.Add(-time.Hour).Unix())
if err != nil {
log.Fatal(err)
}
_, err = verifikasiToken(tokenLama)
fmt.Println("token lama : tidak valid -", err)
}