Endpoint yang kita bangun sejauh ini terbuka untuk siapa pun: cukup tahu alamatnya, siapa pun bisa membaca apalagi menghapus tugas. API sungguhan harus tahu dua hal yang berbeda: siapa pemanggilnya (authentication) dan apa yang boleh dilakukannya (authorization). Keduanya sering diucap sebagai satu kata “auth” padahal gagalnya berbeda — authentication yang gagal berarti 401 Unauthorized, sedangkan authentication yang berhasil tetapi authorization yang gagal berarti 403 Forbidden. Chapter ini menerapkan pola paling sederhana dulu (API key pada header), lalu membahas JWT sebagai format token yang menguasai industri.

API Key pada Header X-API-Key #

Klien membawa kredensial di setiap request — sesuai prinsip stateless dari Chapter Desain REST API. Di FastAPI, pemeriksaan ini justru tidak ditulis di dalam fungsi rute, melainkan sebagai dependency: satu fungsi kecil yang dijalankan sebelum rute (pola dari chapter Dependency Injection):

python
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

API_KEY = "YOUR_API_TOKEN"
ADMIN_KEY = "YOUR_ADMIN_TOKEN"


def verifikasi_api_key(x_api_key: str = Header()):
    if x_api_key != API_KEY:
        raise HTTPException(status_code=401, detail="API key tidak valid")


def pengguna_saat_ini(x_api_key: str = Header()):
    if x_api_key == ADMIN_KEY:
        return {"nama": "Budi", "peran": "admin"}
    if x_api_key == API_KEY:
        return {"nama": "Siti", "peran": "anggota"}
    raise HTTPException(status_code=401, detail="API key tidak valid")


app = FastAPI()
tugas_db = {1: {"id": 1, "judul": "Belajar Python", "selesai": False}}


@app.get("/tugas", dependencies=[Depends(verifikasi_api_key)])
def daftar_tugas():
    return list(tugas_db.values())


@app.delete("/tugas/{task_id}", status_code=204)
def hapus_tugas(task_id: int, pengguna: dict = Depends(pengguna_saat_ini)):
    if pengguna["peran"] != "admin":
        raise HTTPException(status_code=403, detail="Hanya admin yang boleh menghapus")
    if task_id not in tugas_db:
        raise HTTPException(status_code=404, detail=f"Tugas {task_id} tidak ditemukan")
    del tugas_db[task_id]


client = TestClient(app)

r = client.get("/tugas")
print(r.status_code, r.json())

r = client.get("/tugas", headers={"X-API-Key": "kunci-salah"})
print(r.status_code, r.json())

r = client.get("/tugas", headers={"X-API-Key": "YOUR_API_TOKEN"})
print(r.status_code, r.json())

r = client.delete("/tugas/1", headers={"X-API-Key": "YOUR_API_TOKEN"})
print(r.status_code, r.json())

r = client.delete("/tugas/1", headers={"X-API-Key": "YOUR_ADMIN_TOKEN"})
print(r.status_code, repr(r.text))
print(client.get("/tugas", headers={"X-API-Key": "YOUR_API_TOKEN"}).json())

Output yang Dihasilkan:

text
422 {'detail': [{'type': 'missing', 'loc': ['header', 'x-api-key'], 'msg': 'Field required', 'input': None}]}
401 {'detail': 'API key tidak valid'}
200 [{'id': 1, 'judul': 'Belajar Python', 'selesai': False}]
403 {'detail': 'Hanya admin yang boleh menghapus'}
204 ''
[]

Mari kita telaah perannya satu per satu:

  1. Baris 8 (def verifikasi_api_key(x_api_key: str = Header())): annotation Header() memberi tahu FastAPI membaca parameter dari header HTTP; nama snake_case x_api_key otomatis dipetakan ke header X-API-Key.
  2. Baris 13 (def pengguna_saat_ini(...)): dependency tahap dua — setelah kunci cocok, fungsi mengembalikan data pengguna (nama dan peran) yang nanti dipakai keputusan authorization.
  3. Baris 25 (dependencies=[Depends(verifikasi_api_key)]): rute daftar_tugas memakai bentuk dependencies= karena hasil fungsi tidak dibutuhkan — cukup efek penolakannya.
  4. Baris 31 (pengguna: dict = Depends(pengguna_saat_ini)): rute hapus_tugas menangkap hasil dependency sebagai parameter pengguna, lalu baris 32-33 menjadi authorization: peran selain admin ditolak 403.
  5. Baris 41 (panggilan tanpa header): hasilnya 422, bukan 401 — header yang dideklarasikan wajib divalidasi FastAPI lebih dahulu sebelum dependensi kita sempat berjalan. Klien yang benar mengirim kunci salah mendapat 401 (baris 44-45).

Baris 50-55 memperlihatkan dua lapisan itu bekerja: kunci YOUR_API_TOKEN lolos authentication sebagai Siti (anggota) tetapi ditolak 403 saat menghapus; kunci YOUR_ADMIN_TOKEN lolos sebagai Budi (admin) dan DELETE mengembalikan 204 dengan body kosong — panggilan terakhir membuktikan koleksi kini kosong.

Konsep JWT #

API key adalah kredensial panjang umur yang identik untuk semua panggilan. JWT (JSON Web Token) membawa informasi lebih kaya: server menerbitkan token bertanda tangan, lalu token berisi klaim (misalnya sub dan peran) yang bisa diverifikasi tanpa query database. Bentuknya tiga bagian base64url yang dipisah titik: header.payload.signature. Signature dihitung dengan HMAC-SHA256 atas header.payload memakai secret yang hanya diketahui server — payload tetap terbaca siapa pun, tetapi tidak bisa dipalsukan.

Demo minimal berikut (disederhanakan untuk pembelajaran — bukan implementasi produksi) membangun dan memverifikasi token dengan module standar hmac dan hashlib:

python
import base64
import hashlib
import hmac
import json

SECRET = "YOUR_JWT_SECRET"


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def buat_token(payload: dict) -> str:
    header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64url(json.dumps(payload).encode())
    tanda_tangan = b64url(
        hmac.new(f"{header}.{body}".encode(), SECRET.encode(), hashlib.sha256).digest()
    )
    return f"{header}.{body}.{tanda_tangan}"


def verifikasi_token(token: str) -> dict:
    header, body, tanda_tangan = token.split(".")
    tanda_tangan_baru = b64url(
        hmac.new(f"{header}.{body}".encode(), SECRET.encode(), hashlib.sha256).digest()
    )
    if not hmac.compare_digest(tanda_tangan, tanda_tangan_baru):
        raise ValueError("Tanda tangan tidak cocok")
    padding = "=" * (-len(body) % 4)
    return json.loads(base64.urlsafe_b64decode(body + padding))


token = buat_token({"sub": "budi", "peran": "admin"})
print(token)
print(verifikasi_token(token))

try:
    verifikasi_token(token[:-2] + "xx")
except ValueError as e:
    print("Ditolak:", e)

Output yang Dihasilkan:

text
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiYnVkaSIsICJwZXJhbiI6ICJhZG1pbiJ9.ZDg5Hr4uEJ6yBZK6syF53W0SSPprfkt5_aSePgxWHnM
{'sub': 'budi', 'peran': 'admin'}
Ditolak: Tanda tangan tidak cocok

Dua bagian pertama token bisa kita decode kembali menjadi {"alg": "HS256", ...} dan {"sub": "budi", "peran": "admin"} — payload JWT tidak terenkripsi, hanya ditandatangani. Saat dua karakter terakhir diubah, hmac.compare_digest mendeteksi tanda tangan tidak cocok dan token ditolak; klien tidak bisa mengubah klaim peran menjadi admin tanpa memegang SECRET.

OAuth2 dan Catatan Keamanan #

Bagaimana klien mendapatkan token? Alur yang paling umum di FastAPI adalah OAuth2 Password Flow: klien mengirim username dan password ke endpoint khusus (misalnya POST /token) lewat form data, server memverifikasi lalu menerbitkan access token; setiap panggilan berikutnya menyertakan header Authorization: Bearer <token>. FastAPI menyediakan OAuth2PasswordBearer agar alur ini otomatis muncul di halaman /docs — klien bisa mencoba login langsung dari dokumentasi. Terakhir, satu aturan yang tidak bisa ditawar: verifikasi password selalu dengan membandingkan hash (chapter Password Hashing), dan simpan secret di environment variable, tidak pernah di dalam kode (chapter Mengelola Secrets).