İçeriğe geç
Çılgın Yazılım

Güvenlik İleri

JWT ile REST API

PHP ile sıfır bağımlılık JWT REST API: HS256 imza kütüphanesiz yazılmış, API anahtarı hash'li saklanıyor, kapsam (scope) tabanlı yetkilendirme ve anahtar başına kayan pencereli hız sınırı var. alg:none saldırısı, imza kurcalama ve süresi dolmuş jeton senaryoları demoda tek tıkla denenebiliyor.

  • PHP 8
  • PDO
  • MySQL
  • JWT (HS256)
  • REST
  • Ajax
  • Bootstrap 5
php-rest-api-jwt/kunye.json
seviye
"İleri"
dosya
24
satir
~13.463
boyut
"1.8 MB"
veritabani
"cy_api_jwt"
lisans
"MIT"
inceleme
133
begeni
1
yayin
""

Ekran görüntüleri 3 görsel

Bu örnek ne yapıyor?

  • HS256 JWT kütüphanesiz: yaklaşık 60 satırlık jwt_encode / jwt_decode çifti
  • alg jetondan okunmaz — HS256 değilse imza hiç hesaplanmadan reddedilir (alg:none kapalı)
  • İmza hash_equals ile sabit zamanda kıyaslanır; === ilk farklı baytta çıkar ve süre farkı ölçülebilir
  • exp claim'i olmayan jeton reddedilir — süresiz jeton, çalındığında geri alınamayan bir anahtardır
  • iss / aud doğrulaması: aynı sırrı paylaşan iki servis arasında jeton geçişi engellenir
  • API secret'ı veritabanında password_hash ile tutulur; ham hâli hiçbir yerde saklanmaz
  • Anahtar bulunamasa bile sahte bir hash'le doğrulama çalıştırılır (zamanlama saldırısı)
  • Yanlış secret ile pasif anahtar aynı hatayı alır (anahtar sayımı / enumeration)
  • Kapsam tabanlı yetkilendirme: 401 "kim olduğunu bilmiyorum", 403 "biliyorum ama yapamazsın"
  • Yetkisiz kayıt için 403 değil 404 — 403 başkasının kayıt sayısını saymaya yarar
  • Anahtar başına kayan pencereli hız sınırı; X-RateLimit-* başlıkları her yanıtta
  • Tutarlı JSON zarfı: data / meta / error, değişmeyen error.code
  • Allow, Location, WWW-Authenticate, Retry-After standart başlıkları doğru kullanılır
  • Yol üç biçimde çözülür: temiz URL, PATH_INFO, ?path= — mod_rewrite kapalıysa API ölmez
  • Konsolda jeton localStorage'a yazılmaz, yalnızca bellekte tutulur (XSS)
  • Sekiz senaryo düğmesi doğrulamanın her katmanını bilerek kırar ve API'nin cevabını gösterir
  • Mobilde 360px genişlikte yatay kaydırma yok

Nerede işe yarar?

  • Mobil uygulama ya da SPA için jeton tabanlı API yazmak
  • JWT'yi kütüphane arkasında değil, içini görerek öğrenmek
  • Kapsam (scope) tabanlı yetkilendirmeyi ilk kez kurmak
  • 401 ile 403 ayrımını doğru kurgulamak isteyen ekipler
  • Üçüncü taraflara API anahtarı dağıtmak ve yetkilerini sınırlamak
  • Paylaşımlı hostingde Composer kullanamadan API yayınlamak
  • Hız sınırı, tutarlı hata biçimi ve doğru durum kodları için hazır bir kalıp aramak
  • JWT güvenlik açıklarını (alg:none, zamanlama saldırısı) somut örnekle anlatmak

Gereksinimler

  • PHP 8.0+ · MySQL 5.7+ / MariaDB 10.3+ · Apache (mod_rewrite önerilir) · Composer gerekmez

Nasıl kurulur?

  1. Depoyu indirin ya da klonlayın
  2. cy_api.sql dosyasını içe aktarın: mysql -u root -p < cy_api.sql (dosya veritabanını kendisi oluşturur)
  3. system/config.local.php.example dosyasını config.local.php olarak kopyalayın
  4. Kopyadaki DB_* değerlerini ve JWT_SECRET satırını doldurun
  5. Yeni bir imza anahtarı üretin: php -r "echo bin2hex(random_bytes(32));"
  6. Canlıya alırken DEMO_TOKENS değerini false yapın
  7. Demo anahtarlarını silin ya da active = 0 yapın, kendi anahtarlarınızı ekleyin
  8. Tarayıcıda proje klasörünü açın; sağ üstteki "Jeton al" düğmesine basın

Veritabanı şeması projedeki cy_api_jwt.sql dosyasında.

Nasıl çalışıyor?

Bu Proje Nedir?

Bir mobil uygulama, bir SPA ya da başka bir servis, sizin veritabanınızdaki veriye erişecek. Oturum çerezi kullanamıyorsunuz — çerez tarayıcıya aittir, mobil uygulamaya değil. Cevap bilinir: jeton tabanlı API.

Sorun, jetonun kendisini yazmaktır. İnternetteki çoğu örnek şunu yapar:

// Kütüphaneden gelen "kolay" kullanım
$claims = JWT::decode($token, $key, ['HS256', 'RS256', 'none']);
if ($claims->user_id) { /* içeri al */ }

Bu üç satırda üç ayrı açık var:

  1. none algoritmasına izin verilmiş → saldırgan imzayı silip payload'ı istediği gibi yazar
  2. exp hiç kontrol edilmemiş → çalınan jeton sonsuza dek geçerli
  3. Kimlik ile yetki karıştırılmış → jetonu olan herkes her şeyi yapabiliyor

Bu proje o üç soruyu ve bir API'nin diğer beş zor sorusunu cevaplıyor — hepsi kütüphanesiz, yaklaşık 60 satırlık bir jwt_encode / jwt_decode çiftiyle:

  1. Jeton kurcalanırsa? → hash_equals() ile sabit zamanlı HMAC doğrulaması
  2. alg: none gelirse? → algoritma jetondan okunmaz, sabittir
  3. Jeton çalınırsa? → kısa ömür (JWT_TTL) + iss/aud kontrolü
  4. Jetonu olan her şeyi yapabilir mi? → kapsam (scope) tabanlı yetkilendirme
  5. Secret veritabanından sızarsa? → secret hash'li tutulur (password_hash)
  6. Biri uçları döverse? → anahtar başına kayan pencereli hız sınırı
  7. İstemci hatayı nasıl anlar? → değişmeyen error.code ile tutarlı JSON zarfı
  8. Başkasının kaydını isterse? → sahiplik koşulu her sorgunun WHERE'inde

Kimler için uygun?

  • Mobil uygulaması ya da SPA'sı için API yazacaklar
  • JWT'yi kütüphane arkasında değil, içini görerek öğrenmek isteyenler
  • 401 ile 403 arasındaki farkı doğru kurmak isteyenler
  • Kapsam (scope) tabanlı yetkilendirmeyi ilk kez kuracaklar
  • Paylaşımlı hostingde çalışan ve Composer kullanamayanlar
  • Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir tasarım kalıbı arayanlar
Klonla, cy_api_jwt.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.

Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.


Beş Kritik Karar

1. Algoritma jetondan okunmaz

// TİPİK HATALI KOD — algoritmayı saldırgan seçiyor
$header = json_decode(base64_decode($h64), true);
$alg = $header['alg'];               // "none" gelebilir!
if ($alg === 'none') { /* imza yok, geç */ }

// BU PROJEDE — algoritma SABİT
if (!is_array($header) || ($header['alg'] ?? '') !== 'HS256') {
    return [null, 'bad_alg'];        // imza HİÇ hesaplanmadan reddedilir
}

alg: none saldırısı gerçek kütüphanelerde yıllarca yaşadı ve bugün hâlâ kopyalanan örneklerde duruyor. Saldırgan alg değerini none yapar, imza bölümünü siler ve payload'a istediği sub ile scopes değerlerini yazar. Kod algoritmayı jetonun kendisinden okuyorsa, saldırganın kimliğini kendi kendine onaylamış olur.

Doğrusu, sunucunun hangi algoritmayı kabul ettiğini önceden bilmesidir. Demodaki "alg: none saldırısı" senaryosu bu jetonu tarayıcıda üretir ve reddedilişini gösterir.

2. İmza hash_equals ile kıyaslanır

// TİPİK HATALI KOD
if ($expectedSig === $gelenSig) { /* ... */ }

// BU PROJEDE
if (!hash_equals($expectedSig, b64url_decode($s64))) {
    return [null, 'bad_signature'];
}

PHP'nin === operatörü dizeleri bayt bayt kıyaslar ve ilk farklı baytta çıkar. Kıyas süresi, kaç baytın tuttuğuna bağlı olarak değişir; bu fark ağ üzerinden bile ölçülebilir. Saldırgan imzayı bayt bayt tahmin ederek doğru olanı bulabilir.

hash_equals() uzunluktan bağımsız olarak hep aynı süreyi harcar. Bu, jeton doğrulamasının pazarlık edilemez parçasıdır.

3. Kimlik doğrulama ile yetkilendirme ayrı katmanlardır

Authorization: ••••••••
        │
        ├── require_auth()   → jeton geçerli mi?        değilse 401
        │                       (imza, exp, nbf, iss, aud)
        │
        └── require_scope()  → bu işlemi yapabilir mi?  değilse 403
                                (scopes claim'i)

401 "kim olduğunu bilmiyorum" demektir; 403 "kim olduğunu biliyorum ama bunu yapamazsın" demektir. İkisini karıştıran bir istemci 403 aldığında yeni jeton almaya çalışır, aynı 403'ü alır ve sonsuz döngüye girer.

Bu projede dört demo anahtarı bu ayrımı somutlaştırır: demo_writer yazabilir ama okuyamaz. "Yazabiliyorsa okuyabilir" diye bir kural yoktur.

4. Secret veritabanında hash'li tutulur

-- TİPİK HATALI ŞEMA
`secret` VARCHAR(64) NOT NULL,        -- ham secret; sızan döküm = ele geçen API

-- BU PROJEDE
`secret_hash` VARCHAR(255) NOT NULL,  -- password_hash(); geri üretilemez

API anahtarının secret'ı da bir paroladır — yalnızca kullanıcısı insan değil, bir programdır. Anahtar tablosu, sızdırılan veritabanı dökümlerinde ilk bakılan yerdir. Ham secret dursaydı, dökümü ele geçiren herkes API'ye tam yetkiyle girerdi.

Doğrulama password_verify() ile yapılır ve anahtar bulunamasa bile çalıştırılır: yoksa "bu key_id var ama secret yanlış" ile "bu key_id hiç yok" arasındaki süre farkı ölçülebilir hâle gelirdi.

5. Sahiplik koşulu SQL'dedir, PHP'de değil

// TİPİK HATALI KOD — bir yerde unutulur
$not = $db->query("SELECT * FROM api_notes WHERE id = $id")->fetch();
if ($not['owner_key'] !== $claims['sub']) { /* 403 */ }

// BU PROJEDE — unutmak İMKÂNSIZ
$stmt = $db->prepare('SELECT … FROM api_notes WHERE id = :id AND owner_key = :k');

Sahiplik kontrolünü PHP'ye bırakmak, beş uç noktadan birinde unutulması demektir — ve o bir uç, bütün verinin sızması için yeter. Koşul WHERE'e gömülünce kontrol atlanabilir bir adım olmaktan çıkar.

Bir ayrıntı daha: yetkisiz kayıt için 403 değil 404 dönüyoruz. 403, "böyle bir kayıt var ama senin değil" bilgisini verirdi; o bilgi, başkasının kaç kaydı olduğunu saymaya yarar.


Neler Var?

JWT çekirdeği (kütüphanesiz)

  • HS256 imza, hash_hmac + hash_equals
  • alg sabit — none ve algoritma karışıklığı kapalı
  • exp, nbf kontrolü JWT_LEEWAY toleransıyla
  • expi olmayan jeton reddedilir
  • iss / aud doğrulaması
  • jti (jeton kimliği) baştan bulunur
  • base64url encode/decode elle yazılmış

Kimlik ve yetki

  • API anahtarı → kısa ömürlü jeton akışı
  • secret password_hash() ile saklanır
  • Zamanlama saldırısına karşı sahte hash ile doğrulama
  • Anahtar sayımına (enumeration) karşı tek tip hata
  • Kapsam (scope) tabanlı yetkilendirme
  • Kapsamlar beyaz listeden geçer
  • active = 0 ile anahtarı silmeden kapatma

API tasarımı

  • Tutarlı JSON zarfı: data / meta / error
  • Değişmeyen error.code + insan için error.message
  • Doğru durum kodları: 200/201/204/400/401/403/404/405/422/429/500
  • Allow, Location, WWW-Authenticate, Retry-After başlıkları
  • Sayfalama, arama (?q=), sıralama (beyaz listeli)
  • Anahtar başına kayan pencereli hız sınırı
  • GET / ile uç listesi (discovery)
  • CORS + preflight (OPTIONS → 204)

Konsol ve tasarım

  • Jetonun üç parçası renkli, payload tarayıcıda çözülür
  • 8 senaryo: doğrulamayı bilerek kıran istekler
  • İstek geçmişi + detay penceresi + curl karşılığı
  • Paylaşılabilir derin bağlantı (#dene-…, #detay-…)
  • Sayaç şeridi: kalan süre, kapsam, not, kalan istek, anahtar
  • Mobil: 360px'te yatay kaydırma yok
  • Jeton localStorage'a yazılmaz — yalnızca bellekte

Güvenlik: Neyi, Nasıl Kapattık?

AçıkTipik hatalı kodBu projede
alg: none saldırısı$alg = $header['alg']; — algoritma jetondan okunuralg !== 'HS256' ise imza hiç hesaplanmadan reddedilir
Zamanlama saldırısı (imza)if ($sig === $beklenen)hash_equals() — sabit zamanlı kıyas
Zamanlama saldırısı (secret)Anahtar yoksa hemen returnSahte bir hash'le password_verify() yine de çalıştırılır
Süresiz jetonexp hiç kontrol edilmezexp yoksa reddedilir; varsa JWT_LEEWAY toleransıyla kontrol edilir
Servisler arası jeton geçişiiss/aud kontrolü yokİkisi de beklenen değerlerle karşılaştırılır
Yetki = kimlik varsayımıJetonu olan her şeyi yapabilirrequire_scope() — eksikse 403 ve details ile hangi kapsamın gerektiği
Anahtar sayımı (enumeration)"Anahtar bulunamadı" / "secret hatalı" ayrı mesajlarİkisi de tek tip 401 invalid_client
Ham secret saklamasecret VARCHAR(64)secret_hash — password_hash(), geri üretilemez
SQL InjectionDize birleştirme ile sorguTüm sorgular prepared statement, EMULATE_PREPARES = false
ORDER BY enjeksiyonuORDER BY $_GET['sort']Beyaz liste; tanınmayan değer id'ye düşer
LIKE joker istismarıLIKE '%$q%'% ve _ kaçışlanır (ESCAPE '!')
Başkasının kaydına erişimSahiplik PHP'de kontrol edilirowner_key her sorgunun WHERE'inde; yetkisiz kayıt 404
Kaba kuvvet (secret deneme)Sınırsız POST /auth/tokenAnahtar/IP başına 12 istek / 60 sn + Retry-After
Bilgi sızdıran hatalarCanlıda SQL metni ekrana basılırAPP_DEBUG sunucu adından türetilir; canlıda false
Jeton hırsızlığı (XSS)Jeton localStorage'a yazılırKonsolda jeton yalnızca bellekte tutulur
Yapılandırma sızıntısıconfig.php doğrudan indirilebilirsystem/ klasörü tümüyle kapalı + dosya içi CY_APP kontrolü
Şema/veri sızıntısı/cy_api_jwt.sql → HTTP 200.sql, .md, .json, .log, .ini, .bak, .example kapalı (README*.md bilinçli istisna)
ClickjackingBaşlık yokX-Frame-Options: SAMEORIGIN
MIME sniffingBaşlık yokX-Content-Type-Options: nosniff
CSRF neden yok? Bu API çerez kullanmaz; kimlik Authorization başlığıyla taşınır. Tarayıcının kendiliğinden gönderdiği bir kimlik olmadığı için saldırganın sayfası isteği atabilir ama jetonu ekleyemez. CSRF, çerezle taşınan oturumların sorunudur.

Yapılandırma

Tüm ayarlar system/config.php içindedir. Sırlar oraya yazılmaz — system/config.local.php dosyasına yazılır; o dosya .gitignore içindedir, depoya gitmez ve deploy sırasında silinmez.

SabitVarsayılanNe işe yarar
JWT_SECRET(geliştirme değeri)HS256 imza anahtarı. Üretimde mutlaka değiştirin.
JWT_TTL900Jeton ömrü (saniye). Kısa tutun.
JWT_ISS / JWT_AUDcy-rest-api / cy-clientsJetonu üreten ve jetonun geçerli olduğu servis.
JWT_LEEWAY30Sunucu saatleri arası kayma toleransı (saniye).
RATE_LIMIT_TOKEN[12, 60]Jeton üretimi: 12 istek / 60 sn.
RATE_LIMIT_API[180, 60]Normal trafik: 180 istek / 60 sn.
KNOWN_SCOPES3 kapsamTanınan kapsamlar. Listede olmayan kapsam yok sayılır.
DEMO_TOKENStrueBilerek bozuk jeton üreten uç. Üretimde false yapın.
APP_DEBUG(otomatik)Sunucu adından türetilir; canlı alan adında kendiliğinden kapanır.
NOTE_TITLE_MAX / NOTE_BODY_MAX150 / 10000Doğrulama sınırları.
NOTES_PAGE_MAX100Sayfa başına en çok kayıt.

Yeni imza anahtarı üretmek

php -r "echo bin2hex(random_bytes(32));"

Anahtarı değiştirmek, o an dolaşımdaki tüm jetonları geçersiz kılar. Bu bir yan etki değil, elinizdeki tek toplu iptal yöntemidir.

Yeni API anahtarı eklemek

php -r "echo password_hash('secretiniz', PASSWORD_DEFAULT), PHP_EOL;"

INSERT INTO api_keys (name, key_id, secret_hash, scopes)
VALUES ('Mobil uygulama', 'mobil_v1', '$2y$10$…', 'notes:read notes:write profile:read');


Kendi Projenize Eklemek

Bu depodan kendi projenize taşınacak üç dosya vardır:

DosyaNe taşır
system/function.phpJWT çekirdeği, JSON zarfı, hız sınırı, kimlik/kapsam yardımcıları
system/config.phpYapılandırma kalıbı ve config.local.php mekanizması
api/index.phpYönlendirme ve uç noktalar — kendi kaynaklarınızla değiştirin

index.php ile assets/js/console.js API'nin parçası değildir; silebilirsiniz.

Yeni bir uç nokta eklemek

// api/index.php içinde, $claims = require_auth(); satırından SONRA

if ($path === '/urunler' && $method === 'GET') {
    require_scope($claims, 'urunler:read');       // yetki kontrolü
    $stmt = $db->prepare('SELECT id, ad, fiyat FROM urunler WHERE owner_key = :k');
    $stmt->execute([':k' => $claims['sub']]);
    api_ok($stmt->fetchAll());                    // { "data": [...] }
}

Yeni kapsamı KNOWN_SCOPES'a eklemeyi unutmayın — beyaz listede olmayan kapsam, veritabanındaki anahtara yazılsa bile yok sayılır.

JWT çekirdeğini tek başına kullanmak

require 'system/function.php';

$jwt = jwt_encode(['sub' => 'kullanici-42', 'scopes' => ['notes:read']]);

[$claims, $hata] = jwt_decode($jwt);
if ($hata !== null) {
    echo jwt_error_message($hata);   // "Jetonun süresi doldu." vb.
}

Tasarım Kalıbı

Arayüz, tüm Çılgın Yazılım örneklerinde ortak olan tasarım kalıbını kullanır:

DosyaKapsamDeğiştirilir mi?
assets/css/cilginyazilim.cssMarka kalıbı — kartlar, butonlar, tablolar, rozetler, modalHayır. Projeler arası ortaktır.
assets/css/style.cssYalnızca bu sayfaya özgü parçalar (anahtar kartları, JWT gösterimi, senaryo ızgarası)Evet

Yükleme sırası: bootstrap → cilginyazilim → style. Renkler doğrudan yazılmaz, CSS değişkenlerinden okunur (--cy-brand-600, --cy-danger …).

Aynı kalıpla hazırlanmış diğer örnekler: cilginyazilim.com/kutuphane


Dosya Yapısı

.
├── api/
│   ├── .htaccess          → temiz URL yönlendirmesi + Authorization başlığı taşıma
│   └── index.php          → API ÖN DENETLEYİCİ: yönlendirme ve tüm uç noktalar
├── system/
│   ├── .htaccess          → klasör TÜMÜYLE kapalı (Require all denied)
│   ├── config.php         → yapılandırma + PDO bağlantısı
│   ├── config.local.php   → (siz oluşturursunuz; .gitignore içinde)
│   ├── config.local.php.example
│   └── function.php       → JWT, JSON zarfı, hız sınırı, kimlik/kapsam
├── assets/
│   ├── css/               → bootstrap.min · cilginyazilim (marka) · style
│   ├── js/                → jquery · bootstrap.bundle · console.js
│   └── images/logo.png
├── docs/screenshots/
├── .htaccess              → dizin listeleme kapalı, dosya türü kuralları, güvenlik başlıkları
├── .env.example           → Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_api_jwt.sql         → şema + 4 anahtar + 19 not (zamanlar NOW() - INTERVAL ile)
├── index.php              → API KONSOLU (API'nin parçası değildir)
├── CHANGELOG.md
├── LICENSE
├── README.md
└── README.en.md

Nasıl Çalışıyor?

  İSTEMCİ                          API                            VERİTABANI
     │                              │                                  │
     │  POST /auth/token            │                                  │
     │  { key_id, secret }          │                                  │
     ├─────────────────────────────>│                                  │
     │                              │  hız sınırı (12/60 sn)           │
     │                              │  SELECT … WHERE key_id = ?       │
     │                              ├─────────────────────────────────>│
     │                              │  password_verify(secret, hash)   │
     │                              │  active = 1 ?                    │
     │                              │                                  │
     │                              │  jwt_encode({sub, scopes, exp})  │
     │  { data: { token, … } }      │                                  │
     │<─────────────────────────────┤                                  │
     │                                                                 │
     │  GET /notes                                                     │
     │  Authorization: Bearer <jwt>                                    │
     ├─────────────────────────────>│                                  │
     │                              │  1) jwt_decode()                 │
     │                              │     alg = HS256 ?      ─┐        │
     │                              │     hash_equals(imza)   │ 401    │
     │                              │     exp / nbf           │        │
     │                              │     iss / aud          ─┘        │
     │                              │                                  │
     │                              │  2) hız sınırı (anahtar başına)  │
     │                              │                                  │
     │                              │  3) require_scope('notes:read')  │
     │                              │     eksikse ────────────> 403    │
     │                              │                                  │
     │                              │  4) SELECT … WHERE owner_key = ? │
     │                              ├─────────────────────────────────>│
     │  { data: [...], meta: {...} }│                                  │
     │<─────────────────────────────┤                                  │

Sıra bilinçlidir. Hız sınırı kimlikten sonra gelir: sayaç anahtar başınadır. Önce gelseydi IP başına saymak zorunda kalırdık ve tek bir IP'nin (kurumsal ağ, mobil operatör NAT'ı) ardındaki bütün istemciler birbirinin hakkını yerdi.

Kapsam kontrolü ise sorgudan önce gelir: yetkisi olmayan bir istek için veritabanına hiç gidilmez.


API Referansı

Tüm yanıtlar aynı zarfı kullanır:

// Başarılı
{ "data": … , "meta": { … } }        // meta yalnızca gerektiğinde

// Hatalı
{ "error": { "code": "…", "message": "…", "details": { … } } }

error.code makine içindir ve değişmez; error.message insan içindir ve değişebilir. İstemci koşullarını message metnine bağlamamalıdır.

GET / — uç listesi (jeton gerektirmez)

curl -s "$BASE/"

{
  "data": {
    "name": "Çılgın Yazılım · JWT REST API",
    "version": "1.0.0",
    "auth": { "type": "Bearer JWT (HS256)", "token_url": "/auth/token", "expires_in": 900, "scopes": { … } },
    "endpoints": { "GET /notes": "Not listesi (notes:read)", … },
    "rate_limits": { "token": "••••••••", "api": "180/60s" }
  }
}

Kimlik istemez. Uç adresleri sır sayılmaz; gizlilik "kimse yolu bilmesin" ile değil yetkilendirmeyle sağlanır.

POST /auth/token — jeton al

curl -s -X POST "$BASE/auth/token" -H 'Content-Type: application/json' \
  -d '{"key_id":"demo_full","secret":"••••••••"}'

{
  "data": {
    "token": "••••••••",
    "token_type": "Bearer",
    "expires_in": 900,
    "expires_at": "2026-08-31T09:15:00+00:00",
    "scopes": ["notes:read", "notes:write", "profile:read"],
    "key_name": "Mobil uygulama (tam yetki)"
  }
}

DurumKodNe zaman
422invalid_requestkey_id ya da secret boş
401invalid_clientSecret hatalı ya da anahtar pasif (ayırt edilmez)
429rate_limited60 saniyede 12'den fazla deneme

POST /auth/demo-token — bilerek bozuk jeton (yalnızca DEMO_TOKENS açıkken)

curl -s -X POST "$BASE/auth/demo-token" -H 'Content-Type: application/json' \
  -d '{"fault":"expired"}'
faultÜretilen jetonBeklenen sonuç
expiredexp geçmişte401 invalid_token / expired
futurenbf ileri tarihli401 invalid_token / not_yet_valid
bad_audienceaud başka servis401 invalid_token / bad_audience
no_expiryexp alanı yok401 invalid_token / no_expiry
no_scopesGeçerli jeton, kapsam yok403 insufficient_scope

Bu uç bir öğrenme aracıdır. Bozuk ama imzası geçerli bir jeton üretmek için sırra ihtiyaç vardır; sır da istemcide olmamalı. DEMO_TOKENS = false iken uç 404 döner. Ürettiği jetonların hepsi zaten geçersizdir; hiçbiri bir yetki taşımaz.

GET /me — anahtar künyesi (profile:read)

curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"

{
  "data": {
    "name": "Mobil uygulama (tam yetki)",
    "key_id": "demo_full",
    "active": true,
    "scopes": ["notes:read", "notes:write", "profile:read"],
    "created_at": "2026-05-27 00:40:43",
    "last_used_at": "2026-08-31 00:41:40",
    "token": { "jti": "985a34024773adbf", "iat": 1788126100, "exp": 1788127000, "kalan_saniye": 900 }
  }
}

GET /stats — sayaçlar (profile:read)

{ "data": { "notlarim": 13, "son_gun": 4, "anahtar_toplam": 4, "anahtar_aktif": 3,
            "kapsamlarim": ["notes:read","notes:write","profile:read"], "kalan_saniye": 812 } }

Not sayısı yalnızca jetonun sahibi için sayılır; başka anahtarın not sayısı paylaşılmaz.

GET /notes — liste (notes:read)

ParametreVarsayılanNot
page1
limit10En çok 100
q—Başlık ve gövdede arama; % ve _ kaçışlanır
sortidid \title \created_at \updated_at (beyaz liste)
dirdescasc \desc

curl -s "$BASE/notes?q=jeton&sort=title&dir=asc&limit=5" -H "Authorization: Bearer $TOKEN"

{ "data": [ { "id": 5, "title": "…", "body": "…", "created_at": "…", "updated_at": "…" } ],
  "meta": { "page": 1, "limit": 5, "total": 3, "pages": 1, "sort": "title", "dir": "asc", "q": "jeton" } }

POST /notes — oluştur (notes:write)

curl -i -s -X POST "$BASE/notes" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"title":"Yeni not","body":"içerik"}'

201 Created + Location: /notes/138. Yeni kaynağın adresi başlıkta durur — gövdeye gömülü bir alan değil, standart başlık.

DurumKodNe zaman
400invalid_jsonGövde geçerli JSON değil
422validation_faileddetails alanı hangi alanın neden reddedildiğini söyler
403insufficient_scopeJetonda notes:write yok

GET / PUT / DELETE /notes/{id} — tek kayıt

curl -s     "$BASE/notes/12" -H "Authorization: Bearer $TOKEN"
curl -s -X PUT    "$BASE/notes/12" -H "Authorization: Bearer $TOKEN" \
     -H 'Content-Type: application/json' -d '{"title":"Yeni başlık"}'
curl -i -s -X DELETE "$BASE/notes/12" -H "Authorization: Bearer $TOKEN"
  • PUT kısmi güncelleme yapar: gönderilen alanlar güncellenir, gönderilmeyenler korunur. Kitaba göre bu PATCH'in işidir; sadeleştirme bilinçlidir ve burada açıkça yazılıdır.
  • DELETE 204 No Content döner — gövdesiz. 204 gövde taşıyamaz; "null" bile yazılmaz.
  • Başkasının kaydı için 404 döner (403 değil).
  • Koleksiyona (/notes) PUT/DELETE atmak 405 + Allow: GET, POST verir.

Hata kodları — tam liste

HTTPerror.codeAnlamı
400invalid_jsonGövde geçerli JSON değil
401unauthorizedAuthorization başlığı yok
401invalid_tokenJeton geçersiz — details.reason: malformed, bad_alg, bad_signature, bad_payload, expired, not_yet_valid, no_expiry, bad_audience
401invalid_clientkey_id/secret hatalı ya da anahtar pasif
403insufficient_scopeKimlik geçerli, kapsam eksik. details: required, granted
404not_foundKaynak yok ya da başkasına ait
405method_not_allowedAllow başlığı izin verilenleri söyler
422invalid_request / validation_failedGirdi eksik ya da doğrulamadan geçmedi
429rate_limitedRetry-After başlığı kaç saniye bekleneceğini söyler
500server_errorAyrıntı yalnızca APP_DEBUG açıkken döner

Veritabanı Şeması

api_keys
├── id            INT UNSIGNED  AUTO_INCREMENT
├── name          VARCHAR(120)  insan için ad
├── key_id        VARCHAR(64)   UNIQUE — herkese açık tanımlayıcı
├── secret_hash   VARCHAR(255)  password_hash(); ham secret SAKLANMAZ
├── scopes        VARCHAR(255)  boşlukla ayrılmış izinler
├── active        TINYINT(1)    0 = jeton üretilmez
├── created_at    TIMESTAMP
└── last_used_at  DATETIME      son jeton üretim anı

api_notes
├── id            INT UNSIGNED  AUTO_INCREMENT (137'den başlar)
├── owner_key     VARCHAR(64)   jetondaki 'sub' ile eşleşir
├── title         VARCHAR(150)
├── body          TEXT
├── created_at    TIMESTAMP
├── updated_at    TIMESTAMP     ON UPDATE CURRENT_TIMESTAMP
└── KEY idx_notes_owner_id (owner_key, id)
KararNeden
secret_hash, secret değilSızan bir veritabanı dökümü API'yi ele geçirmeye yetmesin.
scopes tek sütun, ayrı tablo değilKapsam sayısı sabit ve az; üçüncü tablo öğretici değil, gürültü olurdu. Kapsamlar dinamikleşirse api_key_scopes doğru cevaptır.
active, DELETE değilAnahtarı kapatmak geri alınabilir olmalı; hangi anahtarın ne zaman kapatıldığı korunur.
owner_key, api_keys.id değilJetonun sub claim'i key_id taşır. id kullansaydık her istekte anahtar tablosuna gitmek gerekirdi — jetonun varlık sebebi tam olarak bu turu ortadan kaldırmaktır.
idx_notes_owner_id (owner_key, id)Liste sorgusu hem owner_key ile filtreler hem id ile sıralar; tek indeks ikisini birden karşılar.
AUTO_INCREMENT = 137Demoda not silinir, numaralar boşalır. Yeni bir kayıt silinmiş bir numarayı devralırsa eski bir bağlantı yanlış kayda gider.

Sık Sorulanlar

Neden bir JWT kütüphanesi kullanmıyorsunuz?

Üretimde kullanabilirsiniz — firebase/php-jwt iyi bir kütüphanedir. Ama bu bir öğretici örnektir: JWT'nin ne olduğunu anlamanın en hızlı yolu, 60 satırlık jwt_encode/jwt_decode çiftini okumaktır.

Ayrıca kütüphane kullanmak açıkları kendiliğinden kapatmaz. alg: none saldırısı yıllarca kütüphanelerin içinde yaşadı; bugün de kütüphaneyi yanlış çağıran kodlarda yaşıyor (decode($t, $k, ['HS256','none'])). Neyi neden yaptığınızı bilmek, kütüphanenin yerini tutmaz ama onsuz kütüphane de sizi korumaz.

Refresh token neden yok?

Bilinçli bir kapsam kararı. Refresh token, kendi başına bir konudur: saklama (HttpOnly çerez), döndürme (rotation), yeniden kullanım tespiti, iptal listesi. Hepsini eklemek bu örneğin anlattığı tek şeyi — erişim jetonunun doğrulanmasını — gölgede bırakırdı.

Pratikte deseni şudur: kısa ömürlü erişim jetonu bellekte, uzun ömürlü refresh token JavaScript'in okuyamadığı bir HttpOnly çerezde durur; erişim jetonu bitince refresh ile yenisi alınır.

Jetonu nasıl iptal ederim?

Doğrudan edemezsiniz — ve bu JWT'nin doğasıdır. Jeton sunucuda saklanmaz; doğrulama yalnızca imzaya bakar. "Çıkış yap" düğmesi jetonu geçersiz kılamaz.

Üç seçeneğiniz var:

  1. Kısa ömür (bu projede 900 sn) — çalınan jeton en fazla o kadar yaşar
  2. JWT_SECRET'i değiştirmek — dolaşımdaki tüm jetonları anında keser
  3. active = 0 — yeni jeton üretilmesini engeller, mevcut jeton expe kadar yaşar

Tek tek iptal gerekiyorsa jti claim'i için bir kara liste tablosu eklenir — ama o noktada her istekte veritabanına gidilir ve JWT'nin durum tutmama avantajı kaybolur. Alan (jti) baştan bulunuyor ki bu adım dolaşımdaki jetonları kırmadan atılabilsin.

Jetonu localStorage'a koysam olmaz mı?

Olur ama riski bilerek alın: sayfadaki herhangi bir XSS açığı localStorage'ı okuyup jetonu dışarı sızdırabilir. Saldırgan o jetonla, ömrü boyunca kullanıcı adına istek atar.

Bu konsolda jeton sıradan bir JavaScript değişkeninde durur; sayfa yenilenince kaybolur. Bu bir eksiklik değil, bilinçli bir ödünleşimdir — bir demo aracının kalıcı oturum tutması gerekmez.

Hız sınırı dosyaya yazıyor, bu ölçeklenir mi?

Tek sunucuda evet. Birden çok sunucunuz varsa hayır: her sunucu kendi payını sayar ve gerçek sınır N katına çıkar. O durumda sayaç ortak bir yerde tutulmalıdır (Redis, Memcached).

Dosya bilerek seçildi: bu örneğin bağımlılığı yok, Redis şart koşmadan çalışsın istiyoruz. Kayan pencere kullanılıyor — sabit pencere (dakikanın başında sıfırlanan sayaç) sınırın iki katına izin verir: 59. saniyede 180, 61. saniyede 180 daha.

Her istekte 401 alıyorum, jeton doğru olmasına rağmen

Büyük olasılıkla Authorization başlığı PHP'ye ulaşmıyor. Bazı Apache/CGI kurulumlarında (mod_php dışındaki SAPI'ler) bu başlık düşürülür ve kod onu gerçekten boş görür.

api/.htaccess başlığı iki ayrı yolla taşır (SetEnvIf ve mod_rewrite), bearer_token() de üç ayrı sunucu değişkenini dener. Yine de olmuyorsa .htaccess dosyalarının okunduğundan emin olun (AllowOverride All).

CORS'u nasıl sınırlarım?

api/index.php başında Access-Control-Allow-Origin: * yazar. Bu, herkese açık bir demo için bilinçlidir. Kendi projenizde origin'i sınırlayın:

$izinli = ['https://uygulamam.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $izinli, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
}

Allow-Credentials açacaksanız * kullanmak zaten yasaktır.


Canlı Ortama Alırken

  • [ ] system/config.local.php oluşturuldu; canlı JWT_SECRET ve veritabanı künyesi orada
  • [ ] JWT_SECRET en az 32 rastgele bayt (bin2hex(random_bytes(32)))
  • [ ] DEMO_TOKENS false
  • [ ] Demo anahtarları (demo_full, demo_readonly, demo_writer, demo_pasif) silindi ya da active = 0 yapıldı
  • [ ] Kendi API anahtarlarınız eklendi; secret'lar password_hash() ile
  • [ ] APP_DEBUG kapalı (canlı alan adında kendiliğinden kapanır — yine de doğrulayın)
  • [ ] Access-Control-Allow-Origin sınırlandı
  • [ ] JWT_TTL sizin için doğru mu? (kısa = güvenli, uzun = az istek)
  • [ ] RATE_LIMIT_* değerleri trafiğinize göre ayarlandı
  • [ ] HTTPS zorunlu — jeton düz metin bir başlıkta taşınır
  • [ ] /cy_api_jwt.sql, /system/config.php, /CHANGELOG.md adresleri 403 dönüyor
  • [ ] index.php ve assets/js/console.js üretimde gerekli mi? Gerekmiyorsa silin

Sorun Giderme

BelirtiSebepÇözüm
Her istek 401, jeton doğruAuthorization başlığı PHP'ye ulaşmıyorAllowOverride All; api/.htaccess okunuyor mu?
/api/notes → 404, /api/index.php?path=/notes çalışıyormod_rewrite kapalıModülü açın ya da ?path= biçimini kullanın
403 alıyorum ama jetonum yeniKapsam eksikdetails.required alanına bakın; yeni jeton almak çözmez
Türkçe karakterler bozuk.sql dosyası yanlış karakter setiyle içe aktarıldımysql --default-character-set=utf8mb4 < cy_api_jwt.sql
SQLSTATE[HY093]Aynı adlı yer tutucu iki kez kullanılmışEMULATE_PREPARES = false iken ad tekrar edemez; :q1, :q2 gibi ayırın
429 sürekli geliyorHız sınırı sayaç dosyalarısys_get_temp_dir()/cy_api_jwt_rate klasörünü silin
Konsolda "Demo jetonu üretilemedi"DEMO_TOKENS = falseBeklenen davranış; üretimde kapalıdır
db_unavailableVeritabanı künyesi yanlışsystem/config.local.php içindeki DB_* değerlerini kontrol edin

Yol Haritası

  • [ ] Refresh token akışı (HttpOnly çerez + rotation)
  • [ ] jti kara listesi ile tek tek jeton iptali
  • [ ] RS256 desteği (asimetrik imza — doğrulayan tarafın sırra ihtiyacı olmaz)
  • [ ] Redis tabanlı hız sınırı sürücüsü
  • [ ] OpenAPI (Swagger) tanımı
  • [ ] Anahtar yönetimi arayüzü

Katkı

Katkılar memnuniyetle karşılanır.

  1. Depoyu çatallayın (fork)
  2. Bir dal açın: git checkout -b ozellik/harika-sey
  3. Değişikliklerinizi işleyin: git commit -m 'Harika şey eklendi'
  4. Dalı gönderin: git push origin ozellik/harika-sey
  5. Pull request açın

Hata bildirimi ve öneriler için Issues bölümünü kullanabilirsiniz.


Lisans

MIT — bkz. LICENSE. Ticari projelerde de özgürce kullanabilirsiniz.


Çılgın Yazılım · Kütüphane · GitHub

Bu örneği faydalı bulduysanız ⭐ vermeyi unutmayın.

Kaynak kod ağaçtan bir dosya seçin

rest-api-jwt
Dosya seçilmedi

İncelemek istediğiniz dosyayı ağaçtan seçin.

Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).

Sık sorulan sorular

Neden bir JWT kütüphanesi kullanmıyor?

Üretimde kullanabilirsiniz; firebase/php-jwt iyi bir kütüphanedir. Ama bu öğretici bir örnek: JWT'nin ne olduğunu anlamanın en hızlı yolu 60 satırlık encode/decode çiftini okumaktır. Ayrıca kütüphane kullanmak açıkları kendiliğinden kapatmaz — alg:none saldırısı yıllarca kütüphanelerin içinde yaşadı ve bugün de onları yanlış çağıran kodlarda yaşıyor.

alg:none saldırısı nedir?

Saldırgan jetonun header bölümündeki alg değerini "none" yapar, imza bölümünü siler ve payload'a istediği kimliği ve yetkileri yazar. Algoritmayı jetonun kendisinden okuyan kod, saldırganın kimliğini kendi kendine onaylamış olur. Bu projede alg HS256 değilse jeton imza hiç hesaplanmadan reddedilir; demoda bu senaryo tek tıkla denenebilir.

401 ile 403 arasındaki fark ne?

401 "kim olduğunu bilmiyorum" demektir: jeton yok, süresi dolmuş ya da imzası bozuk. 403 ise "kim olduğunu biliyorum ama bunu yapamazsın" demektir: jeton kusursuz, eksik olan yetki. İkisini karıştıran bir istemci 403 aldığında yeni jeton almaya çalışır, aynı 403'ü alır ve sonsuz döngüye girer. Bu örnekte dört demo anahtarı bu ayrımı somutlaştırır.

Jetonu nasıl iptal ederim?

Doğrudan edemezsiniz; bu JWT'nin doğasıdır. Jeton sunucuda saklanmaz, doğrulama yalnızca imzaya bakar. Üç seçenek vardır: kısa ömür (bu örnekte 900 saniye), JWT_SECRET'i değiştirmek (dolaşımdaki tüm jetonları anında keser) ve anahtarı active = 0 yapmak (yeni jeton üretilmesini engeller). Tek tek iptal gerekiyorsa jti claim'i için bir kara liste tablosu eklenir — ama o noktada her istekte veritabanına gidilir ve JWT'nin durum tutmama avantajı kaybolur.

Neden refresh token yok?

Bilinçli bir kapsam kararı. Refresh token kendi başına bir konudur: HttpOnly çerezde saklama, döndürme (rotation), yeniden kullanım tespiti, iptal listesi. Hepsini eklemek bu örneğin anlattığı tek şeyi — erişim jetonunun doğrulanmasını — gölgede bırakırdı. Yol haritasında duruyor.

Jetonu localStorage'a koyabilir miyim?

Koyabilirsiniz ama riski bilerek alın: sayfadaki herhangi bir XSS açığı localStorage'ı okuyup jetonu dışarı sızdırabilir ve saldırgan o jetonla ömrü boyunca kullanıcı adına istek atar. Bu örnekteki konsolda jeton sıradan bir JavaScript değişkeninde durur ve sayfa yenilenince kaybolur. Kalıcılık gerekiyorsa doğru desen, JavaScript'in okuyamadığı HttpOnly bir çerezle taşınan refresh token'dır.

Hız sınırı dosyaya yazıyor, bu ölçeklenir mi?

Tek sunucuda evet. Birden çok sunucuda hayır: her sunucu kendi payını sayar ve gerçek sınır kat kat artar; o durumda sayaç Redis gibi ortak bir yerde tutulmalıdır. Dosya bilerek seçildi, çünkü bu örneğin hiçbir bağımlılığı yok. Kayan pencere kullanılıyor — sabit pencere, dakikanın başında sıfırlandığı için sınırın iki katına izin verir.

Bu örneğin daha gelişmişi projenizde mi lazım?

İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.

Projenizi Anlatın

Yorumlar

Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.

Soru sorun veya yorum yazın

Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.

Yayınlanmaz; yalnızca gerektiğinde size ulaşmak için.

En az 10 karakter.

İlgili kod örnekleri

Tüm kod örnekleri