PKCE ile OAuth 2.0 Güvenliğini Artırma
OAuth 2.0’ı production ortamında kullanan herkes er ya da geç şu soruyla yüzleşiyor: “Authorization code flow güvenli mi, yoksa daha iyisini yapabilir miyim?” Özellikle mobil uygulamalar ve Single Page Application’lar söz konusu olduğunda, klasik authorization code flow’un ciddi açıkları var. İşte tam bu noktada PKCE devreye giriyor ve oyunun kurallarını değiştiriyor.
PKCE, “Proof Key for Code Exchange” kelimelerinin kısaltması ve RFC 7636 ile standart hale geldi. Türkçe telaffuzuyla “piksee” olarak da duyacaksınız. Temelde şunu söylüyor: “Client secret’ı saklayamıyorsan, başka bir mekanizma kullanalım.” Bu mekanizma hem zarif hem de son derece pratik.
Neden PKCE’ye İhtiyaç Var?
Klasik OAuth 2.0 authorization code flow’unda işler şöyle yürür: Kullanıcı login olmak ister, uygulaman authorization server’a yönlendirir, kullanıcı orada kimlik doğrular, server sana bir authorization code döner, sen bu kodu client_secret ile birlikte token endpoint’ine göndererek access token alırsın.
Masaüstü ya da mobil uygulamalarda problem tam burada başlıyor. Client secret’ı nereye koyacaksın? Mobil uygulama paketinin içine mi? Birisi APK’yı unpackage edip bakarsa ne olur? Ya da SPA uygulamalarında JavaScript kodunun içinde mi tutacaksın? Bunların hiçbiri güvenli değil.
Daha da kötüsü var: Authorization Code Interception Attack. Bazı işletim sistemlerinde, kötü niyetli bir uygulama senin uygulamanla aynı custom URL scheme’i kaydederse, authorization code’u araya girip çalabilir. Kullanıcı myapp://callback?code=xyz123 adresine yönlendirildiğinde, kötü niyetli uygulama bu callback’i senin uygulamandan önce yakalayabilir.
PKCE bu sorunu şöyle çözüyor:
- Her authorization isteğinde rastgele bir code_verifier oluşturulur
- Bu verifier’dan kriptografik bir code_challenge türetilir
- Challenge, authorization isteğiyle birlikte gönderilir
- Token alınırken orijinal verifier gönderilir, server bunları karşılaştırır
- Authorization code çalınsa bile, verifier olmadan işe yaramaz
PKCE’nin Teknik Temelleri
PKCE’nin çalışma mantığını anlamak için iki temel kavramı kavramak gerekiyor.
code_verifier: 43 ile 128 karakter arasında, URL-safe random bir string. Karakter seti şunlarla sınırlı: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~". Her authentication akışı için yeni bir tane oluşturulmalı.
code_challenge: Verifier’dan türetilen değer. İki yöntem var:
- plain: Challenge direkt verifier’ın kendisi (güvenlik açısından zayıf, sadece PKCE desteklemeyen serverlar için fallback)
- S256: Verifier’ın SHA-256 hash’inin base64url encode edilmiş hali (her zaman bunu kullan)
S256 için matematiksel ifade: code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
Python ile PKCE Implementasyonu
Önce Python’da sıfırdan bir PKCE mekanizması yazalım. Bu kodu anlarsak, her dilde uygulayabiliriz.
pip install requests cryptography
import secrets
import hashlib
import base64
import urllib.parse
import requests
def generate_code_verifier(length=64):
"""RFC 7636 uyumlu code_verifier üretir"""
# URL-safe karakterler kullanarak rastgele string oluştur
token = secrets.token_urlsafe(length)
# 43-128 karakter arasında olmalı
return token[:128]
def generate_code_challenge(code_verifier):
"""S256 metoduyla code_challenge türetir"""
# SHA-256 hash al
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
# Base64url encode et (padding olmadan)
challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
return challenge
def build_authorization_url(
auth_endpoint,
client_id,
redirect_uri,
scope,
code_verifier,
state=None
):
"""PKCE parametrelerini içeren authorization URL'i oluşturur"""
code_challenge = generate_code_challenge(code_verifier)
params = {
'response_type': 'code',
'client_id': client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
if state:
params['state'] = state
# State yoksa güvenlik için oluştur
if not state:
params['state'] = secrets.token_urlsafe(16)
return f"{auth_endpoint}?{urllib.parse.urlencode(params)}", params['state']
def exchange_code_for_token(
token_endpoint,
code,
code_verifier,
client_id,
redirect_uri
):
"""Authorization code'u access token ile değiştirir"""
data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'client_id': client_id,
'code_verifier': code_verifier, # Secret yok, verifier var!
}
response = requests.post(token_endpoint, data=data)
response.raise_for_status()
return response.json()
# Kullanım örneği
verifier = generate_code_verifier()
print(f"Code Verifier: {verifier}")
print(f"Code Challenge: {generate_code_challenge(verifier)}")
auth_url, state = build_authorization_url(
auth_endpoint='https://auth.example.com/oauth/authorize',
client_id='my-public-client',
redirect_uri='https://myapp.com/callback',
scope='openid profile email',
code_verifier=verifier
)
print(f"Authorization URL: {auth_url}")
Bu implementasyonda dikkat çekmek istediğim nokta: client_secret parametresi hiçbir yerde yok. Token endpoint’ine sadece code_verifier gönderiyoruz.
Node.js ile Express Uygulamasında PKCE
Gerçek hayatta çok sık karşılaşılan senaryo: Node.js tabanlı bir web uygulaması, harici bir OAuth provider ile entegre olacak.
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax' }
}));
// PKCE yardımcı fonksiyonlar
function generateCodeVerifier() {
return crypto.randomBytes(32)
.toString('base64url')
.substring(0, 128);
}
function generateCodeChallenge(verifier) {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Login endpoint'i
app.get('/auth/login', (req, res) => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString('hex');
// Verifier ve state'i session'a kaydet
req.session.codeVerifier = codeVerifier;
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
const authUrl = `${process.env.AUTH_SERVER}/authorize?${params}`;
res.redirect(authUrl);
});
// Callback endpoint'i
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
// Hata kontrolü
if (error) {
return res.status(400).json({ error: `Auth hatası: ${error}` });
}
// State doğrulama - CSRF koruması
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'State mismatch - olası CSRF saldırısı' });
}
const codeVerifier = req.session.codeVerifier;
// Session temizle - replay attack önlemi
delete req.session.codeVerifier;
delete req.session.oauthState;
try {
const tokenResponse = await axios.post(
`${process.env.AUTH_SERVER}/token`,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.REDIRECT_URI,
client_id: process.env.CLIENT_ID,
code_verifier: codeVerifier
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
req.session.tokens = tokenResponse.data;
res.redirect('/dashboard');
} catch (err) {
console.error('Token exchange hatası:', err.response?.data);
res.status(500).json({ error: 'Token alınamadı' });
}
});
Authorization Server Tarafında Doğrulama
Eğer kendi OAuth server’ını yazıyorsan veya var olan bir sisteme PKCE desteği ekliyorsan, server tarafında doğrulamanın nasıl yapıldığını bilmek gerekiyor.
import hashlib
import base64
import secrets
from datetime import datetime, timedelta
class PKCEAuthorizationServer:
def __init__(self):
# Gerçekte bu bir veritabanı olmalı
self.pending_authorizations = {}
def handle_authorization_request(self, params):
"""
Authorization isteğini işler ve code_challenge'ı saklar
"""
required = ['client_id', 'response_type', 'redirect_uri',
'code_challenge', 'code_challenge_method']
for field in required:
if field not in params:
raise ValueError(f"Eksik parametre: {field}")
if params['code_challenge_method'] not in ['S256', 'plain']:
raise ValueError("Desteklenmeyen challenge method")
# S256'yı zorla, plain'i reddet (güvenlik politikası)
if params['code_challenge_method'] == 'plain':
raise ValueError("plain method desteklenmiyor, S256 kullanın")
# Authorization code oluştur
auth_code = secrets.token_urlsafe(32)
# Code ve challenge'ı birlikte sakla
self.pending_authorizations[auth_code] = {
'client_id': params['client_id'],
'redirect_uri': params['redirect_uri'],
'code_challenge': params['code_challenge'],
'code_challenge_method': params['code_challenge_method'],
'expires_at': datetime.now() + timedelta(minutes=10),
'used': False
}
return auth_code
def handle_token_request(self, params):
"""
Token isteğini işler ve code_verifier'ı doğrular
"""
code = params.get('code')
code_verifier = params.get('code_verifier')
if not code or not code_verifier:
raise ValueError("code ve code_verifier gerekli")
auth_data = self.pending_authorizations.get(code)
if not auth_data:
raise ValueError("Geçersiz authorization code")
# Süre kontrolü
if datetime.now() > auth_data['expires_at']:
del self.pending_authorizations[code]
raise ValueError("Authorization code süresi dolmuş")
# Tek kullanım kontrolü
if auth_data['used']:
# Bu çok önemli: Tekrar kullanım girişimi saldırı işareti!
# Tüm ilgili token'ları iptal et
self._revoke_all_tokens(auth_data['client_id'])
raise ValueError("Authorization code zaten kullanılmış - güvenlik ihlali")
# PKCE doğrulama - işte kritik kısım bu
if not self._verify_code_challenge(
code_verifier,
auth_data['code_challenge'],
auth_data['code_challenge_method']
):
raise ValueError("Code verifier doğrulaması başarısız")
# Kodu kullanıldı olarak işaretle
auth_data['used'] = True
return self._generate_tokens(auth_data['client_id'])
def _verify_code_challenge(self, verifier, challenge, method):
"""PKCE doğrulama mantığı"""
if method == 'S256':
computed = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode('ascii')).digest()
).rstrip(b'=').decode('ascii')
# Timing attack'ı önlemek için secrets.compare_digest kullan
return secrets.compare_digest(computed, challenge)
elif method == 'plain':
return secrets.compare_digest(verifier, challenge)
return False
def _generate_tokens(self, client_id):
return {
'access_token': secrets.token_urlsafe(32),
'token_type': 'Bearer',
'expires_in': 3600
}
def _revoke_all_tokens(self, client_id):
print(f"UYARI: {client_id} için tüm tokenlar iptal edildi")
Burada secrets.compare_digest kullanımına özellikle dikkat et. String karşılaştırmalarında timing attack riski var. Normal == operatörü karakterleri sırayla karşılaştırır ve eşleşmeyen ilk karakterde durur. Bu süre farkından bir saldırgan bilgi çıkarabilir. compare_digest sabit zamanda karşılaştırma yaparak bunu önler.
Mobil Uygulama Senaryosu: React Native
Mobil uygulamalarda PKCE kullanımı biraz farklı çünkü browser redirect yerine deep link veya universal link mekanizması kullanılıyor.
import * as AuthSession from 'expo-auth-session';
import * as Crypto from 'expo-crypto';
import AsyncStorage from '@react-native-async-storage/async-storage';
async function generatePKCE() {
// Güvenli random byte üret
const randomBytes = await Crypto.getRandomBytesAsync(32);
// Base64url encode
const verifier = btoa(String.fromCharCode(...randomBytes))
.replace(/+/g, '-')
.replace(///g, '_')
.replace(/=/g, '');
// SHA-256 hash
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
verifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
const challenge = hash
.replace(/+/g, '-')
.replace(///g, '_')
.replace(/=/g, '');
return { verifier, challenge };
}
async function startOAuthFlow() {
const { verifier, challenge } = await generatePKCE();
// Verifier'ı güvenli storage'a kaydet
await AsyncStorage.setItem('pkce_verifier', verifier);
const redirectUri = AuthSession.makeRedirectUri({
scheme: 'myapp',
path: 'oauth-callback'
});
const authUrl = `https://auth.example.com/authorize?` +
`response_type=code&` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`scope=openid%20profile&` +
`code_challenge=${challenge}&` +
`code_challenge_method=S256&` +
`state=${Math.random().toString(36).substring(7)}`;
return { authUrl, redirectUri };
}
async function handleCallback(code) {
const verifier = await AsyncStorage.getItem('pkce_verifier');
if (!verifier) {
throw new Error('PKCE verifier bulunamadı');
}
// Verifier'ı sil - tek kullanımlık
await AsyncStorage.removeItem('pkce_verifier');
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: verifier
})
});
return response.json();
}
Nginx ile PKCE Endpoint’lerini Güvenli Hale Getirme
Production’da PKCE endpoint’lerinizi reverse proxy arkasına almanız ve rate limiting uygulamanız şart.
# /etc/nginx/conf.d/oauth-security.conf
# Authorization endpoint için rate limiting zone
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
# Token endpoint - daha kısıtlayıcı
limit_req_zone $binary_remote_addr zone=token_limit:10m rate=5r/m;
server {
listen 443 ssl http2;
server_name auth.example.com;
# Authorization endpoint
location /oauth/authorize {
limit_req zone=auth_limit burst=5 nodelay;
limit_req_status 429;
# HTTPS zorunlu
if ($scheme != "https") {
return 301 https://$server_name$request_uri;
}
proxy_pass http://auth_backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Clickjacking önlemi
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
}
# Token endpoint
location /oauth/token {
limit_req zone=token_limit burst=3 nodelay;
limit_req_status 429;
# Sadece POST
if ($request_method != POST) {
return 405;
}
# Content-Type kontrolü
if ($content_type !~ "application/x-www-form-urlencoded") {
return 400;
}
proxy_pass http://auth_backend;
# Cache engelle
add_header Cache-Control "no-store";
add_header Pragma "no-cache";
}
}
Yaygın Hatalar ve Çözümleri
Production’da PKCE implementasyonu yaparken karşılaşılan tipik sorunlara bakalım.
Verifier’ı session’a kaydetmemek: Her istek için yeni verifier üretip nereye kaydettiğini unutmak çok yaygın. SPA’larda sessionStorage kullan, localStorage değil. Tab kapatıldığında temizlenmesi önemli.
State parametresini atlamak: PKCE varken state’e gerek yok sanılıyor. Yanlış! State CSRF koruması için, PKCE ise code interception için. İkisi farklı tehditleri ele alıyor.
Plain method kullanmak: Bazen library uyumsuzlukları nedeniyle plain fallback’e geçildiği görülüyor. Bu PKCE’yi neredeyse anlamsız kılıyor. S256’yı server tarafında zorunlu kıl.
Code verifier’ı loglamak: Debug sırasında tüm parametreleri loglayan kod production’a gidebiliyor. Verifier ve token hiçbir zaman loglanmamalı.
# Yanlış - bunu yapma
import logging
logger = logging.getLogger(__name__)
def exchange_code(code, verifier):
logger.debug(f"Token exchange: code={code}, verifier={verifier}") # YANLIS!
# ...
# Doğru
def exchange_code(code, verifier):
logger.debug(f"Token exchange başlatıldı: code_prefix={code[:6]}...") # Sadece prefix
# verifier asla loglanmaz
Verifier uzunluğunu küçük tutmak: RFC minimumu 43 karakter ama 64 veya 96 karakter kullanmak daha iyi entropi sağlıyor.
Mevcut Sistemlere PKCE Entegrasyonu
Keycloak, Auth0, Azure AD gibi popüler sistemlerde PKCE’yi etkinleştirmek için bazı notlar:
Keycloak’ta client ayarlarında “PKCE Code Challenge Method” alanını S256 olarak seç. Public client için client secret’ı devre dışı bırak ve “Standard Flow Enabled” seçeneğini açık tut.
Auth0’da Application Settings’te “PKCE Enabled” toggle’ını aç. Confidential client değilse zaten PKCE zorunlu hale geliyor.
Azure AD’de app registration’da authentication bölümünden public client flows’u etkinleştir. MSAL library kullanıyorsan PKCE otomatik olarak devreye giriyor.
Spring Security OAuth2 kullananlar için:
# application.yml
spring:
security:
oauth2:
client:
registration:
my-provider:
client-id: ${CLIENT_ID}
# client-secret YOK - public client
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid,profile,email
provider:
my-provider:
authorization-uri: https://auth.example.com/authorize
token-uri: https://auth.example.com/token
user-info-uri: https://auth.example.com/userinfo
Spring Security 5.7+ PKCE’yi public clientlar için otomatik devreye alıyor.
PKCE ile Birlikte Kullanılması Gereken Diğer Önlemler
PKCE tek başına yeterli değil. Güçlü bir OAuth implementasyonu için şunlar da şart:
- Kısa ömürlü authorization code: Maximum 10 dakika, ideal olarak 5 dakika. Kodun çalınma riskini minimize eder.
- Tek kullanımlık code: Kod bir kez kullanıldıktan sonra invalidate edilmeli. Tekrar kullanım girişimi alarm tetiklemeli.
- Token Rotation: Refresh token her kullanımda yenilenmeli, eski geçersiz sayılmalı.
- Audience validation: Access token’ın hangi resource server için olduğu doğrulanmalı.
- Redirect URI exact match: Prefix matching değil, exact match zorunlu tutulmalı.
# Redirect URI doğrulama - güvenli yaklaşım
def validate_redirect_uri(requested_uri, registered_uris):
"""
Wildcard veya prefix matching KULLANMA.
Tam eşleşme zorunlu.
"""
# URL'yi normalize et
from urllib.parse import urlparse, urlunparse
def normalize(uri):
parsed = urlparse(uri)
# Fragment kısmını kaldır (güvenlik riski)
return urlunparse(parsed._replace(fragment=''))
normalized_requested = normalize(requested_uri)
for registered in registered_uris:
if normalize(registered) == normalized_requested:
return True
return False # Exact match bulunamazsa reddet
Sonuç
PKCE, modern OAuth 2.0 uygulamalarında artık bir “nice to have” değil, zorunlu bir güvenlik katmanı. RFC 9700 (OAuth 2.0 Security Best Current Practice) tüm OAuth istemcileri için PKCE kullanımını öneriyor, yalnızca public clientlar için değil.
Özetlemek gerekirse: Her yeni authentication flow başladığında kriptografik olarak güçlü bir verifier üret, ondan türetilen challenge’ı server’a gönder, callback’ten sonra verifier ile token al. Bu kadar basit ama sağladığı güvenlik artışı dramatik.
Production’da dikkat etmen gerekenler:
- S256’yı zorunlu kıl, plain’i reddet
- State parametresini asla atlama
- Verifier ve code’u asla loglama
- Authorization code’u tek kullanımlık ve kısa ömürlü yap
- Rate limiting’i mutlaka uygula
- Redirect URI’yi tam eşleşmeyle doğrula
Eski sistemleri de geçiş sürecinde güncellemek gerekiyor. Confidential clientlar bile artık PKCE kullanabilir ve kullanmalı. Ek bir yük getirmiyor, sadece güvenliği artırıyor. OAuth’u doğru uygulamak zahmetli görünebilir ama bir authorization code çalındığında o zahmeti değeceğini anlıyorsun.
