Kısa cevap: JWT ile Bearer kimlik doğrulamada kullanıcı bir kez e-posta ve parolasıyla oturum açar; sunucu ona kullanıcı kimliğini ve son geçerlilik zamanını (exp) içeren, gizli bir anahtarla imzalanmış bir token verir. İstemci sonraki her istekte bu token'ı Authorization: Bearer eyJ... başlığıyla gönderir. Sunucu oturum tablosuna bakmadan yalnızca imzayı ve süreyi kontrol eder; ikisi de geçerliyse isteği işler, token'ın süresi dolmuş ya da imzası tutmuyorsa WWW-Authenticate başlığıyla 401 döner. Sunucunun oturum bilgisi saklamaması nedeniyle bu yönteme durumsuz (stateless) kimlik doğrulama denir.
Bu yazıda akışı uçtan uca, PyJWT 2.10 ve FastAPI 0.128 ile çalıştırılmış örneklerle kuruyoruz: token üretme, doğrulama, hata türlerini 401'e çevirme ve sık hatalar. JWT'ye yönelik saldırılar (algoritma karışıklığı, zayıf gizli anahtar gibi) JWT güvenlik rehberinin, OAuth 2.0 akışları ve yenileme token'ları OAuth 2.0 ve OIDC rehberinin konusu; burada tekrar edilmiyor. Serinin tamamı REST API tasarımı öğrenme rehberi sayfasında.
Durumsuz kimlik doğrulama ne demek?
Klasik oturum yönteminde sunucu, oturum açan her kullanıcı için bir kayıt tutar ve her istekte o kaydı arar. JWT yaklaşımında gerekli bilgi token'ın içindedir: Kullanıcının kimliği, token'ın üretildiği ve geçerliliğini yitireceği zaman. Sunucu bu bilgiye güvenebilir, çünkü token kendi gizli anahtarıyla imzalanmıştır ve içerik değiştirilirse imza tutmaz.
Bunun iki önemli sonucu vardır. Birincisi, birden fazla API sunucusu aynı gizli anahtarı bildiği sürece ortak bir oturum deposuna ihtiyaç duymadan token'ı doğrulayabilir. İkincisi, sunucu bir token'ı "unutamaz": İmzası geçerli ve süresi dolmamış bir token, kullanıcı çıkış yapsa bile kendiliğinden geçersiz olmaz. Bu yüzden token ömrünü kısa tutmak tasarımın parçasıdır. RFC 6750, bearer token'ların ömrünün sınırlandırılmasını zorunlu tutar ve özellikle tarayıcı gibi sızıntı riski olan ortamlar için bir saat veya daha kısa süreli token önerir.
Token'ın yapısı
Bir JWT, noktalarla ayrılmış üç parçadan oluşan bir metindir: başlık, yük (payload) ve imza. İlk iki parça base64url ile kodlanmış JSON'dur. Kodlama şifreleme değildir; token'ı ele geçiren herkes yükü okuyabilir. Bu yüzden yüke parola, kart numarası gibi gizli bilgiler konmaz; yalnızca kimlik ve yetki kararı için gereken asgari bilgi taşınır.
Token'ın başlıkta nasıl gönderildiğini RFC 6750 tanımlar: Bearer şeması, bir boşluk ve token. Standart, istemcinin token'ı tek istekte birden fazla yoldan göndermemesini ve Authorization başlığını tercih etmesini söyler; kaynak sunucusunun bu yöntemi desteklemesi zorunludur. JWT'nin parçalarında kullanılan base64url karakterleri ve nokta, Bearer söz dizimindeki b64token kuralına uyar.
GET /v1/ben HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiJ9.imzaPyJWT ile üretme ve doğrulama

Skill'deki örnek datetime.utcnow() kullanır; bu fonksiyon Python 3.12'de kullanımdan kaldırılmış (deprecated) olarak işaretlidir. Aşağıda yerine saat dilimi bilgisi taşıyan datetime.now(timezone.utc) kullanıyoruz. Örnek, doğrulamanın başarılı ve başarısız olduğu beş durumu sırayla deniyor:
from datetime import datetime, timedelta, timezone
import jwt
GIZLI = "deneme-icin-uzun-bir-gizli-anahtar-0123456789" # gerçekte ortam değişkeninden okunur
def token_uret(kullanici_id: int, sure: timedelta) -> str:
simdi = datetime.now(timezone.utc)
yuk = {"sub": str(kullanici_id), "iat": simdi, "exp": simdi + sure}
return jwt.encode(yuk, GIZLI, algorithm="HS256")
tok = token_uret(42, timedelta(hours=1))
print(type(tok).__name__, tok.count("."))
# çıktı: str 2
yuk = jwt.decode(tok, GIZLI, algorithms=["HS256"])
print(yuk["sub"], yuk["exp"] - yuk["iat"])
# çıktı: 42 3600
eski = token_uret(42, timedelta(seconds=-5))
try:
jwt.decode(eski, GIZLI, algorithms=["HS256"])
except jwt.ExpiredSignatureError as e:
print("ExpiredSignatureError:", e)
# çıktı: ExpiredSignatureError: Signature has expired
print(jwt.decode(eski, GIZLI, algorithms=["HS256"], leeway=30)["sub"])
# çıktı: 42
baslik, _, imza = tok.split(".")
sahte_yuk = jwt.utils.base64url_encode(b'{"sub":"1","exp":4102444800}').decode()
bozuk = f"{baslik}.{sahte_yuk}.{imza}" # yük değişti, imza eski
try:
jwt.decode(bozuk, GIZLI, algorithms=["HS256"])
except jwt.InvalidSignatureError as e:
print("InvalidSignatureError:", e, issubclass(jwt.InvalidSignatureError, jwt.InvalidTokenError))
# çıktı: InvalidSignatureError: Signature verification failed True
try:
jwt.decode(tok, GIZLI)
except jwt.DecodeError as e:
print("DecodeError:", e)
# çıktı: DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode().
sayisal = jwt.encode({"sub": 42}, GIZLI, algorithm="HS256")
try:
jwt.decode(sayisal, GIZLI, algorithms=["HS256"])
except jwt.exceptions.InvalidSubjectError as e:
print("InvalidSubjectError:", e)
# çıktı: InvalidSubjectError: Subject must be a stringÇıktılardan çıkan dersleri tek tek ele alalım:
- exp otomatik kontrol edilir. PyJWT,
expalanı olan bir token'ı çözerken süreyi kendisi denetler ve süre geçmişseExpiredSignatureErrorfırlatır. Sizin ayrıca tarih karşılaştırması yazmanız gerekmez. - leeway saat farkını tolere eder. Token'ı üreten ve doğrulayan sunucuların saatleri birkaç saniye farklı olabilir.
leeway=30, süresi 5 saniye önce dolmuş token'ı kabul etti. Bu payı küçük tutun; büyük bir pay token ömrünü fiilen uzatır. - Yükü değiştirmek imzayı bozar. Saldırganın
subdeğerini değiştirip eski imzayı kullandığı tokenInvalidSignatureErrorile reddedildi. Bu sınıf,InvalidTokenErrorsınıfının alt sınıfıdır. - algorithms parametresi zorunludur. Kabul edilen algoritma listesi verilmeden yapılan çağrı hata verir. Listeyi her zaman açıkça yazın; bu kuralın güvenlikle ilgisi JWT güvenlik rehberinde anlatılıyor.
- sub bir metin olmalıdır. Skill'in örneği kendi tanımladığı
user_idalanına sayı koyar ve bu çalışır. Ancak standartsubalanını kullanırsanız PyJWT 2.10 sayısal değeriInvalidSubjectErrorile reddeder. Kimliğistr()ile yazıp okurken sayıya çevirmek bu sorunu çözer.
Hata türlerini 401'e çevirmek

PyJWT'nin fırlattığı hataların hepsi InvalidTokenError sınıfından türer: Süre dolması, imza hatası, eksik zorunlu alan, geçersiz sub hep bu ailededir. Bu yüzden except bloklarının sırası önemlidir. Önce özel durumu, ExpiredSignatureError sınıfını yakalarsınız, çünkü istemciye "süre doldu, yenile" demek istersiniz. Ardından geri kalan her şeyi InvalidTokenError ile yakalarsınız. Sıra ters olursa genel blok her şeyi yutar ve süre dolması ayrı bir mesaj olarak hiç görünmez.
Bu hataların hiçbiri 500 olmamalıdır. Bozuk ya da eski bir token istemcinin sorunudur ve RFC 6750, süresi dolmuş, iptal edilmiş, bozuk ya da başka sebeple geçersiz token için invalid_token hata kodunu ve 401 yanıtını tanımlar. 401 ile birlikte WWW-Authenticate başlığı da RFC 9110 gereği zorunludur.
FastAPI ile uçtan uca akış
Aşağıdaki uygulamada iki uç nokta var. POST /v1/oturumlar kimlik bilgisini kontrol edip token üretiyor. Yanıt gövdesinin biçimini RFC 6750'nin örnek erişim token yanıtından aldık: access_token, token_type ve saniye cinsinden expires_in. Aynı örnekte yanıtın Cache-Control: no-store taşıdığı görülür; token içeren bir yanıtın önbelleğe alınmasını istemezsiniz. GET /v1/ben ise korumalı uç nokta; kimliği aktif_kullanici bağımlılığından alıyor. Kullanıcı tablosu bellekte bir sözlük; gerçek uygulamada parola özetiyle birlikte veritabanında durur.
import os
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.testclient import TestClient
from pydantic import BaseModel
GIZLI = os.environ.get("JWT_GIZLI", "deneme-icin-uzun-bir-gizli-anahtar-0123456789")
SURE = timedelta(hours=1)
KULLANICILAR = {"ayse@example.com": {"id": 42, "parola": "dogru-parola"}} # gerçekte parola özeti saklanır
app = FastAPI()
bearer = HTTPBearer(auto_error=False)
class Giris(BaseModel):
eposta: str
parola: str
def hata(aciklama: str | None = None):
deger = 'Bearer realm="api"'
if aciklama:
deger += f', error="invalid_token", error_description="{aciklama}"'
return HTTPException(401, aciklama or "Kimlik bilgisi gerekli", headers={"WWW-Authenticate": deger})
@app.post("/v1/oturumlar")
def oturum_ac(giris: Giris, response: Response):
k = KULLANICILAR.get(giris.eposta)
if k is None or k["parola"] != giris.parola:
raise HTTPException(401, "E-posta veya parola hatalı", headers={"WWW-Authenticate": 'Bearer realm="api"'})
simdi = datetime.now(timezone.utc)
tok = jwt.encode({"sub": str(k["id"]), "iat": simdi, "exp": simdi + SURE}, GIZLI, algorithm="HS256")
response.headers["Cache-Control"] = "no-store"
return {"access_token": tok, "token_type": "Bearer", "expires_in": int(SURE.total_seconds())}
def aktif_kullanici(cred: HTTPAuthorizationCredentials | None = Depends(bearer)) -> int:
if cred is None:
raise hata()
try:
yuk = jwt.decode(cred.credentials, GIZLI, algorithms=["HS256"], options={"require": ["exp", "sub"]})
except jwt.ExpiredSignatureError:
raise hata("The access token expired")
except jwt.InvalidTokenError:
raise hata("The access token is invalid")
return int(yuk["sub"])
@app.get("/v1/ben")
def ben(kullanici_id: int = Depends(aktif_kullanici)):
return {"id": kullanici_id}
c = TestClient(app)
r = c.post("/v1/oturumlar", json={"eposta": "ayse@example.com", "parola": "yanlis"})
print(r.status_code, r.json())
# çıktı: 401 {'detail': 'E-posta veya parola hatalı'}
r = c.post("/v1/oturumlar", json={"eposta": "ayse@example.com", "parola": "dogru-parola"})
govde = r.json()
print(r.status_code, r.headers["cache-control"], govde["token_type"], govde["expires_in"])
# çıktı: 200 no-store Bearer 3600
tok = govde["access_token"]
r = c.get("/v1/ben", headers={"Authorization": f"Bearer {tok}"})
print(r.status_code, r.json())
# çıktı: 200 {'id': 42}
eski = jwt.encode({"sub": "42", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, GIZLI, algorithm="HS256")
r = c.get("/v1/ben", headers={"Authorization": f"Bearer {eski}"})
print(r.status_code, r.headers["www-authenticate"])
# çıktı: 401 Bearer realm="api", error="invalid_token", error_description="The access token expired"
expsiz = jwt.encode({"sub": "42"}, GIZLI, algorithm="HS256")
r = c.get("/v1/ben", headers={"Authorization": f"Bearer {expsiz}"})
print(r.status_code, r.json())
# çıktı: 401 {'detail': 'The access token is invalid'}
r = c.get("/v1/ben")
print(r.status_code, r.headers["www-authenticate"])
# çıktı: 401 Bearer realm="api"options={"require": ["exp", "sub"]} satırı önemli bir boşluğu kapatıyor. PyJWT, exp alanı olan token'ın süresini denetler; fakat bu alan hiç yoksa varsayılan olarak token'ı süresiz kabul eder. Zorunlu alan listesiyle, exp içermeyen token örnekte görüldüğü gibi 401 alıyor. Süresi dolan token ise error_description ile birlikte invalid_token hatası alıyor; böylece istemci yeniden oturum açması gerektiğini makine okunur biçimde öğreniyor.
Akışın tel üzerindeki görünümü şöyle. Önce oturum açma isteği ve yanıtı:
POST /v1/oturumlar HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"eposta": "ayse@example.com", "parola": "dogru-parola"}HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiJ9.imza", "token_type": "Bearer", "expires_in": 3600}Bir saat sonra aynı token ile yapılan istek ise şu yanıtı alır:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Content-Type: application/json
{"detail": "The access token expired"}Örnekteki token metinleri okunabilirlik için kısaltılmıştır; gerçek bir imza bu kadar kısa değildir.
Hedef kitle (aud) kısıtlaması
RFC 6750, token yönlendirme (token redirect) riskine karşı yetkilendirme sunucusunun token'a hedef kitleyi, yani token'ı kabul etmesi beklenen kaynak sunucusunu yazmasını önemli bulur ve token'ların belirli bir kapsamla sınırlandırılmasını önerir. Aynı gizli anahtarı paylaşan iki servisiniz varsa, sipariş API'si için üretilmiş bir token'ın fatura API'sinde de kabul edilmesi istemediğiniz bir sonuçtur. PyJWT bu kontrolü audience parametresiyle yapar:
import jwt
GIZLI = "deneme-icin-uzun-bir-gizli-anahtar-0123456789"
tok = jwt.encode({"sub": "42", "aud": "siparis-api", "exp": 4102444800}, GIZLI, algorithm="HS256")
print(jwt.decode(tok, GIZLI, algorithms=["HS256"], audience="siparis-api")["aud"])
# çıktı: siparis-api
for hedef in ["fatura-api", None]:
try:
jwt.decode(tok, GIZLI, algorithms=["HS256"], audience=hedef)
except jwt.InvalidAudienceError as e:
print(type(e).__name__ + ":", e)
# çıktı: InvalidAudienceError: Audience doesn't match
# çıktı: InvalidAudienceError: Invalid audienceİkinci çıktı ilginçtir: Token'da aud alanı varken doğrulayan taraf hiç audience vermezse PyJWT token'ı reddeder. Yani aud eklemeye karar verdiğinizde, token'ı doğrulayan bütün servislerin kendi adlarını bildirmesi gerekir. Bu hatalar da InvalidTokenError ailesindendir ve yukarıdaki uygulamada 401 olarak döner.
Kapsam (scope) ya da rol bilgisi taşıyan token'larda bir ayrımı hatırlatalım: Token geçerli ama istenen işlem için kapsamı yetmiyorsa bu artık bir kimlik hatası değil, yetki hatasıdır. RFC 6750 bunun için insufficient_scope ve 403 tanımlar.
Altyapıya dair bir uyarı da RFC 6750'den gelir: Yük dengeleyici kullanan kurulumlarda TLS bağlantısı asıl uygulama sunucusundan önce sonlanabilir ve token ön sunucu ile arka sunucu arasında korumasız kalabilir. Standart, bu aralıkta da gizliliği sağlayacak önlemlerin alınmasını zorunlu tutar.
İstemci tarafında token kullanımı
Akışın istemci yarısı da tasarımın parçasıdır. Oturum yanıtındaki expires_in değeri, istemcinin token'ın ne zaman geçersiz olacağını yükü çözmeden bilmesini sağlar; istemci bu süreyi kaydedip süre dolmadan yeni bir token alabilir. Buna rağmen saatler arasındaki farklar yüzünden zaman zaman 401 alınacaktır. Sağlam bir istemci 401 ve invalid_token gördüğünde token'ı bir kez yeniler ve isteği bir kez tekrarlar; yine 401 alırsa kullanıcıyı oturum açmaya yönlendirir ve döngüye girmez.
RFC 6750'nin özet önerilerinin başında token'ın istenmeyen taraflara sızmaması gelir. Bunun istemci tarafındaki karşılığı, token'ı yalnızca onu üreten API'nin adresine göndermek ve TLS sertifika zincirini doğrulamaktır. Standart, istemcinin token'ı sunarken kaynak sunucusunun kimliğini doğrulamasını zorunlu tutar; aksi hâlde sahte bir sunucu token'ı ele geçirip kullanabilir.
Gizli anahtar, süre ve çıkış
Skill'in örneği gizli anahtarı koda "your-secret-key" olarak yazar. Bu yalnızca bir yer tutucudur. Gerçek uygulamada anahtar ortam değişkeninden ya da bir gizli bilgi yöneticisinden okunur, uzun ve rastgele olur ve kod deposuna girmez. HS256 gibi simetrik bir algoritmada token'ı doğrulayabilen herkes yeni token da üretebilir; bu yüzden anahtarı bilen servis sayısını az tutun.
Süre seçimi bir dengedir. Kısa ömür, sızan bir token'ın verebileceği zararı sınırlar ama kullanıcının sık sık yeniden oturum açmasına yol açar. Bu dengeyi kurmanın bir yolu, kısa ömürlü erişim token'ını daha uzun ömürlü bir yenileme token'ıyla birlikte kullanmaktır; bu konu OAuth 2.0 rehberinde ve yenileme token'larının dönüşümlü kullanımı JWT güvenlik rehberinde anlatılıyor.
Çıkış işlemi durumsuz modelin zayıf noktasıdır. İstemci token'ı silse bile, kopyası olan biri süresi dolana kadar onu kullanabilir. Bunu kapatmanın yolu, iptal edilen token'ların kısa bir listesini sunucuda tutmaktır; ama o zaman her istekte bir kayda bakmış olursunuz ve model kısmen durumlu hâle gelir. Hiçbir çözüm bedelsiz değildir; kısa ömür çoğu durumda en basit savunmadır.
Sık yapılan hatalar

- Token'ı URL'de taşımak. RFC 6750 bearer token'ların sayfa URL'lerinde gönderilmemesini önerir; geçmiş ve günlükler bu değeri saklayabilir.
- exp koymamak ya da zorunlu tutmamak. Süresiz bir token sızdığında sonsuza kadar geçerlidir.
- Yüke gizli bilgi koymak. Yük kodlanmıştır, şifrelenmemiştir.
- Doğrulama hatasını 500'e dönüştürmek. Yakalanmayan bir
InvalidTokenErroristemciye sunucu hatası gibi görünür. - 401'de WWW-Authenticate göndermemek. RFC 9110 bunu zorunlu tutar ve istemci yeniden kimlik doğrulama gerektiğini bu başlıktan anlar.
- Token yanıtını önbelleğe açık bırakmak. Token üreten yanıtlarda
Cache-Control: no-storekullanın. - HTTPS kullanmamak. RFC 6750 bearer token içeren her istek için TLS kullanımını zorunlu tutar.
Sık Sorulan Sorular
JWT ile API anahtarı arasında nasıl seçim yaparım?
Bir kullanıcı adına çalışan, kısa ömürlü kimlik gerekiyorsa JWT; bir servisin başka bir servisi çağırdığı, iptalin anında etkili olması gereken durumlarda API anahtarı daha uygundur. Ayrıntılı karşılaştırma API key ile kimlik doğrulama yazısında.
Token süresi dolduğunda 401 mi 403 mü dönmeliyim?
401. Süresi dolmuş token artık geçerli bir kimlik bilgisi değildir. 403, token geçerli olduğu hâlde kullanıcının yetkisi yetmediğinde kullanılır. Sınırın ayrıntısı 401 ve 403 farkı yazısında.
Token'ı istemcide nerede saklamalıyım?
RFC 6750, bearer token'ların şifresiz gönderilebilecek çerezlerde saklanmamasını, çerezde saklanıyorsa siteler arası istek sahteciliğine (CSRF) karşı önlem alınmasını şart koşar. Tarayıcı tarafındaki saklama seçenekleri ve riskleri bu yazının kapsamı dışında; genel çerçeve için REST API güvenlik rehberine bakın.
Her istekte veritabanına hiç gitmeyecek miyim?
Kimlik doğrulama için gitmeniz gerekmez; imza ve süre kontrolü yeterlidir. Ancak kullanıcının hesabının askıya alınması gibi anlık değişen durumları yakalamak istiyorsanız bir kayda bakmanız gerekir. Bu karar, durumsuzluğun getirdiği hız ile anlık iptal imkânı arasında bir seçimdir.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: REST API tasarımı öğrenme rehberi · JWT güvenlik rehberi · OAuth 2.0 ve OIDC rehberi · API key ile kimlik doğrulama · 401 ve 403 farkı · REST API güvenlik rehberi · FastAPI deploy rehberi