Mert Özen Her Satırda Daha İleri
Güvenlik Frontend Güvenliği — #05

Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli

Mert Özen 8 Kas 2026 14 dk 4 görüntülenme
Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli

Tarayıcıda hiçbir saklama yeri XSS'in kullanıcı adına istek atmasını engelleyemez; fark, token'ın okunup dışarı taşınabilmesinde ve bu yüzden RFC 10017 token'ı tarayıcıya hiç vermeyen BFF'i öneriyor. Bu yazıda localStorage, memory ve HttpOnly cookie modellerini Angular 22 ve ASP.NET Core 10 lab'ında simüle edilmiş bir XSS ile deniyor, Web/Service Worker ve refresh token risklerini tartıyor ve Bookstore'un BFF kararını testlerle sabitliyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, .NET 10, Chrome 156 ve Playwright 1.65 ile denenmiştir.

Önce Tahmin Et

Bookstore'un Angular uygulamasında bir XSS (Cross-Site Scripting) açığı çıktı ve saldırganın script'i kullanıcının sayfasında çalışıyor. Üç ayrı ekip üç ayrı karar vermiş: birincisi access token'ı localStorage'da tutuyor, ikincisi yalnız JavaScript memory'sinde, üçüncüsü token'ı tarayıcıya hiç vermiyor ve HttpOnly session cookie'si kullanıyor. Hangi ekipte saldırgan kullanıcı adına sipariş verebilir? Hangisinde token'ı alıp kendi makinesinden, tarayıcı kapandıktan sonra da kullanabilir?

"HttpOnly cookie XSS'e karşı korur" cümlesi yarı doğru. Bu yazıda o yarıyı kendi lab'ımızda ayırıyoruz: token'ı okumak ile kullanıcının yerine istek atmak iki farklı tehdit ve saklama yerlerinin her biri bu ikisine karşı farklı davranıyor. Sonra RFC (Request for Comments) 10017'nin önerisine bakıp Bookstore için bir karar veriyoruz. Bütün denemeler kendi makinemdeki lab'a karşı.

Tehdit Modeli: Token'ı Okumak ile Kullanıcı Adına İstek Atmak

Ağustos 2026'da BCP (Best Current Practice) 212 olarak yayımlanan RFC 10017 "OAuth 2.0 for Browser-Based Applications", sayfada kötü niyetli JavaScript çalıştığında olabilecekleri dört senaryoya ayırıyor. Single-execution token theft: script o anki token'ları storage'dan alıp gönderir. Persistent token theft: script kalıcı bir düzen kurar ve her yenilenen token'ı da alır; kısa ömürlü token ve rotation bunu durdurmaz. Acquisition and extraction of new tokens: script gizli bir iframe'de yeni bir authorization akışı başlatıp kendine taze token alır. Proxying requests via the user's browser: script hiçbir token çalmadan, uygulamanın context'inden kullanıcı adına istek atar.

İlk üçü "token'ı okumak" ailesi. Sonuçları ağır: çalınan token saldırganın makinesinde, tarayıcı kapandıktan sonra da, token'ın ömrü boyunca çalışır; çalınan bir refresh token ise bu süreyi günlere uzatabilir. Dördüncüsü "kullanıcı adına istek" ve onu hiçbir saklama yeri durduramaz: script senin origin'inde çalıştığı sürece uygulamanın yapabildiği her şeyi yapabilir. Bu yüzden saklama yeri seçimi XSS'in kendisini önlemez, yalnız XSS'in hasarını sınırlar. XSS'i önlemek bu serinin strict CSP (Content Security Policy) ve Trusted Types yazılarının işi.

Lab: Üç Saklama Modeli, Tek Angular Uygulaması

Bookstore'un sipariş API'si (Application Programming Interface) iki tür credential kabul ediyor: Authorization: Bearer header'ı ya da __Host-session cookie'si. Token'lar ve session'lar bellekte tutulan rastgele 32 byte'lık değerler; JWT (JSON Web Token) kullanmadım, çünkü konumuz token'ın formatı değil nerede durduğu. ASP.NET Core 10 tarafı:

using System.Collections.Concurrent;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
ILogger log = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Bookstore.TokenLab");

ConcurrentDictionary<string, string> accessTokens = new();
ConcurrentDictionary<string, string> sessions = new();
int nextOrderId = 1000;

static string NewSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));

app.UseDefaultFiles();
app.UseStaticFiles();

// Model 1: token JavaScript'e verilir (localStorage ya da memory)
app.MapPost("/api/token/login", () =>
{
    string token = NewSecret();
    accessTokens[token] = "ada";
    return Results.Ok(new { accessToken = token });
});

// Model 2: BFF tarzı; tarayıcı yalnız HttpOnly session cookie'si görür
app.MapPost("/api/session/login", (HttpContext context) =>
{
    string sessionId = NewSecret();
    sessions[sessionId] = "ada";
    context.Response.Cookies.Append("__Host-session", sessionId, new CookieOptions
    {
        Secure = true,
        HttpOnly = true,
        SameSite = SameSiteMode.Strict,
        Path = "/",
    });
    return Results.NoContent();
});

app.MapPost("/api/orders", (HttpContext context) =>
{
    string? user = null;
    string via = "none";
    string authorization = context.Request.Headers.Authorization.ToString();
    if (authorization.StartsWith("Bearer ") && accessTokens.TryGetValue(authorization[7..], out string? tokenUser))
    {
        (user, via) = (tokenUser, "bearer");
    }
    else if (context.Request.Cookies.TryGetValue("__Host-session", out string? sessionId)
        && sessions.TryGetValue(sessionId, out string? sessionUser))
    {
        (user, via) = (sessionUser, "cookie");
    }

    if (user is null)
    {
        log.LogWarning("Order rejected: no valid credential");
        return Results.Unauthorized();
    }

    int orderId = Interlocked.Increment(ref nextOrderId);
    log.LogInformation("Order {OrderId} created for {User} via {Via}, user-agent={Agent}",
        orderId, user, via, context.Request.Headers.UserAgent.ToString().Split(' ')[0]);
    return Results.Created($"/api/orders/{orderId}", new { orderId, via });
});

app.Run();

public partial class Program;

Angular 22 tarafında tek bir AuthStore üç modu da biliyor. local modu token'ı localStorage'a yazıyor, memory modu bir private field'da tutuyor, cookie modu token'ı hiç görmüyor:

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';

export type AuthMode = 'local' | 'memory' | 'cookie';

@Injectable({ providedIn: 'root' })
export class AuthStore {
  private readonly http = inject(HttpClient);
  private memoryToken: string | null = null;
  private mode: AuthMode = 'cookie';

  async login(mode: AuthMode): Promise<void> {
    this.mode = mode;
    if (mode === 'cookie') {
      // Token tarayıcıya hiç gelmez; sunucu HttpOnly session cookie'si bırakır
      await firstValueFrom(this.http.post('/api/session/login', { user: 'ada' }));
      return;
    }
    const { accessToken } = await firstValueFrom(
      this.http.post<{ accessToken: string }>('/api/token/login', { user: 'ada' }),
    );
    if (mode === 'local') {
      // Yaygın ama riskli: aynı origin'deki her script okuyabilir
      localStorage.setItem('bookstore.accessToken', accessToken);
    } else {
      this.memoryToken = accessToken;
    }
  }

  token(): string | null {
    if (this.mode === 'local') {
      return localStorage.getItem('bookstore.accessToken');
    }
    return this.mode === 'memory' ? this.memoryToken : null;
  }
}

Token'ı isteğe ekleyen functional interceptor, birçok projede gördüğün koda çok benziyor:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from './auth.store';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthStore).token();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Component, URL'deki (Uniform Resource Locator) mode parametresine göre giriş yapıyor ve bir "Place order" butonu gösteriyor:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AuthMode, AuthStore } from './auth/auth.store';

@Component({
  selector: 'app-root',
  template: `
    <p id="status">{{ status() }}</p>
    <button id="place-order" (click)="placeOrder()">Place order</button>
  `,
})
export class App implements OnInit {
  private readonly auth = inject(AuthStore);
  private readonly http = inject(HttpClient);
  protected readonly status = signal('signing in');

  async ngOnInit(): Promise<void> {
    const mode = (new URLSearchParams(location.search).get('mode') ?? 'cookie') as AuthMode;
    await this.auth.login(mode);
    this.status.set(`ready (${mode})`);
  }

  async placeOrder(): Promise<void> {
    const order = await firstValueFrom(
      this.http.post<{ orderId: number; via: string }>('/api/orders', { bookId: 42, quantity: 1 }),
    );
    this.status.set(`order ${order.orderId} via ${order.via}`);
  }
}

ng build çıktısını ASP.NET Core'un wwwroot klasörüne kopyaladım; uygulama ve API aynı origin'de, https://shop.bookstore.test:19402 adresinde. Sertifika önceki yazıdaki gibi çalışma klasöründe openssl ile üretildi.

XSS Simülasyonu: Script Neyi Okuyabilir, Ne Yapabilir?

Uygulamaya gerçek bir açık eklemek yerine, enjekte edilmiş script'i Playwright'ın page.evaluate'i ile sayfanın kendi context'inde çalıştırdım. Sonuç aynı: kod, sayfanın origin'inde ve sayfanın yetkileriyle çalışıyor. Script önce okunabilecek ne varsa okuyor, sonra kendi başına bir sipariş vermeyi deniyor:

// XSS'i simüle eder: bu fonksiyon sayfanın kendi origin'inde çalışır
const injected = async () => {
  const stored = localStorage.getItem('bookstore.accessToken');
  const headers = { 'Content-Type': 'application/json' };
  if (stored) headers.Authorization = `Bearer ${stored}`;
  const response = await fetch('/api/orders', { method: 'POST', headers, body: JSON.stringify({ bookId: 7, quantity: 3 }) });
  return { stored, cookie: document.cookie, injectedOrder: response.status };
};

Probe her mod için temiz bir browser context açıyor, script'i çalıştırıyor, sonra uygulamanın kendi butonuna tıklıyor ve son olarak script'in okuyabildiği değerle tarayıcı dışından curl isteği atıyor:

for (const mode of ['local', 'memory', 'cookie']) {
  const context = await browser.newContext({ ignoreHTTPSErrors: true });
  const page = await context.newPage();
  await page.goto(`${APP}/?mode=${mode}`);
  await page.waitForSelector('#status:text("ready")');

  const result = await page.evaluate(injected);
  console.log(`\n### mode=${mode}`);
  console.log(`localStorage token : ${result.stored ? result.stored.slice(0, 8) + '...' : 'null'}`);
  console.log(`document.cookie    : "${result.cookie}"`);
  console.log(`injected fetch     : ${result.injectedOrder}`);

  await page.click('#place-order');
  await page.waitForSelector('#status:text("order")');
  console.log(`app button         : ${await page.textContent('#status')}`);

  const outside = result.stored ? curlOrder(['-H', `Authorization: Bearer ${result.stored}`]) : curlOrder([]);
  console.log(`curl from outside  : ${outside}`);
  await context.close();
}
### mode=local
localStorage token : trdZy0oD...
document.cookie    : ""
injected fetch     : 201
app button         : order 1002 via bearer
curl from outside  : 201

### mode=memory
localStorage token : null
document.cookie    : ""
injected fetch     : 401
app button         : order 1004 via bearer
curl from outside  : 401

### mode=cookie
localStorage token : null
document.cookie    : ""
injected fetch     : 201
app button         : order 1006 via cookie
curl from outside  : 401

Sunucu logu aynı hikâyeyi anlatıyor (satır başındaki kategori adını kısalttım):

Order 1001 created for ada via bearer, user-agent=Mozilla/5.0
Order 1002 created for ada via bearer, user-agent=Mozilla/5.0
Order 1003 created for ada via bearer, user-agent=curl/8.7.1
warn: Order rejected: no valid credential
Order 1004 created for ada via bearer, user-agent=Mozilla/5.0
warn: Order rejected: no valid credential
Order 1005 created for ada via cookie, user-agent=Mozilla/5.0
Order 1006 created for ada via cookie, user-agent=Mozilla/5.0
warn: Order rejected: no valid credential

Üç satırı yan yana oku. local modunda script token'ı okudu, sipariş verdi ve aynı token curl'den de kabul edildi: 1003 numaralı siparişin user-agent'ı curl/8.7.1. Bu en kötü durum, çünkü token artık tarayıcıdan bağımsız. memory modunda script token'ı bulamadı ve kendi fetch'i 401 aldı; ama uygulamanın butonuna tıklamak yetti, interceptor token'ı kendisi ekledi. cookie modunda script document.cookie'de hiçbir şey göremedi, ama kendi fetch'i 201 aldı, çünkü cookie'yi tarayıcı ekliyor. Dışarıdan atılan curl ise 401 aldı: saldırganın elinde taşıyabileceği bir değer yok.

Saklama Yerleri Tek Tek

05-storage-threats-tr.png

localStorage aynı origin'deki her script'e açık ve kalıcı; tarayıcı kapanıp açıldığında da orada. sessionStorage sekmeye bağlı, sekmeler arasında paylaşılmıyor; RFC 10017 bunun riski yalnız "biraz" azalttığını söylüyor, çünkü script aynı sekmede çalışıyor. İkisinin API'si JavaScript serisinin "Web Storage ve Cookie" yazısında anlatıldı; buradaki fark, ikisinin de XSS karşısında aynı derecede açık olması.

Memory'de tutmak, token'ı sayfa yenilenince kaybetmek pahasına okumayı zorlaştırıyor. Lab'da script token'ı bulamadı. Ama RFC 10017 burada da dikkatli: memory'deki değer, prototype poisoning gibi yollarla, örneğin uygulamanın kullandığı bir global fonksiyonu değiştirerek bir sonraki istekte ele geçirilebilir. Üstelik dördüncü senaryo hiç etkilenmiyor; lab'da 1004 numaralı sipariş bunu gösterdi.

Web Worker ve Service Worker, token'ı ayrı bir execution context'te tutup uygulamaya hiç vermeme fikrine dayanıyor. RFC 10017'ye göre Service Worker yaklaşımı token hırsızlığını engelleyebilir, ama token'ı ana pencereyle paylaşılan kalıcı bir storage'a (IndexedDB dahil) yazmamalı; Web Worker ile yaygın kurulumda refresh token worker'da kalıyor, access token ise uygulamaya veriliyor ve çalınabiliyor. İkisi de saldırganın yeni bir authorization akışı başlatıp kendine taze token almasını engellemiyor.

HttpOnly cookie'nin değeri JavaScript'e hiç görünmüyor; lab'daki document.cookie çıktısı boş. Token'ı okumaya dayanan üç senaryonun üçüne de karşı en iyi seçenek bu, yeter ki cookie'nin arkasında access token değil sunucudaki bir session olsun. Bedeli CSRF (Cross-Site Request Forgery): tarayıcı cookie'yi kendiliğinden eklediği için başka bir sitenin isteğine de ekleyebilir. Önceki yazıda gördüğümüz SameSite=Strict ve Fetch Metadata kontrolü bu bedelin büyük kısmını öder.

Refresh Token Tarayıcıda

Access token birkaç dakika yaşar, refresh token ise günlerce. Bu yüzden tarayıcıda tutulan bir refresh token, XSS'in hasarını dakikalardan günlere taşır. RFC 10017, refresh token'ı tarayıcıdaki bir public client'a veren authorization server'lar için şunları zorunlu kılıyor: her kullanımda rotation ya da sender-constrained token, bir azami ömür ya da kullanılmayan token'ın süresinin dolması ve rotation sırasında bu ömrün uzatılmaması.

Sender-constrained token, örneğin DPoP (Demonstrating Proof of Possession), çalınan token'ı tek başına işe yaramaz hâle getirir: saldırganın private key'e de ihtiyacı vardır. RFC, key'in WebCrypto ile non-extractable üretilmesini öneriyor; o zaman XSS key'i dışarı çıkaramaz. Yine de bu, script'in sayfa açıkken o key'le imza attırmasını engellemez ve saldırganın kendi key'ine bağlı yeni bir akış başlatmasını da durdurmaz.

RFC 10017'nin Önerisi ve Bookstore İçin Karar

RFC 10017 üç mimariyi "azalan güvenlik sırasıyla" veriyor. En üstte BFF (Backend for Frontend): confidential bir OAuth client olan backend token'ları sunucuda tutar, tarayıcıyla cookie tabanlı bir session kurar ve API isteklerini proxy'ler. İkincisi token-mediating backend: token'ı backend alır ama access token'ı tarayıcıya verir, tarayıcı API'yi doğrudan çağırır; refresh token korunur, access token çalınabilir. Üçüncüsü tamamen tarayıcıda çalışan public client: bütün senaryolara açık. RFC, iş uygulamaları için BFF'i "strongly recommended" diye niteliyor.

Bookstore için kararımız BFF. Angular uygulaması token görmeyecek, API çağrıları aynı origin'deki ASP.NET Core backend'e cookie ile gidecek, backend OAuth token'larını sunucuda tutup arkadaki servislere iletecek. RFC'nin BFF cookie'si için önerileri: Secure ve HttpOnly zorunlu, SameSite=Strict, Path=/, Domain yok ve __Host-Http- prefix'i. Lab'da __Host- kullandım; MDN'in (MDN Web Docs) verisine göre __Host-Http- Chrome 140 ve Firefox 143'te var, Safari'de henüz yok. Lab'ın ürettiği header:

curl -sk -i --resolve shop.bookstore.test:19402:127.0.0.1 -X POST https://shop.bookstore.test:19402/api/session/login
HTTP/2 204
set-cookie: __Host-session=BO2pDHsNfwiI0g4ay2%2BP2TaZ264%2FqVlMQo45H7YRBaQ%3D; path=/; secure; samesite=strict; httponly

Bu kararı iki testle sabitledim. İlki cookie'nin attribute'larını kontrol ediyor; biri yanlışlıkla HttpOnly'yi kaldırırsa ya da Domain eklerse build kırılıyor:

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Bookstore.TokenLab.Tests;

public class SessionCookieTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Session_cookie_is_host_only_httponly_secure_and_strict()
    {
        HttpClient client = factory.CreateClient();

        HttpResponseMessage response = await client.PostAsync("/api/session/login", null);

        string setCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"));
        Assert.StartsWith("__Host-session=", setCookie);
        Assert.Contains("path=/", setCookie);
        Assert.Contains("secure", setCookie);
        Assert.Contains("samesite=strict", setCookie);
        Assert.Contains("httponly", setCookie);
        // __Host- prefix'i Domain attribute'una izin vermez
        Assert.DoesNotContain("domain=", setCookie);
    }

    [Fact]
    public async Task Order_without_credentials_is_rejected()
    {
        HttpClient client = factory.CreateClient();

        HttpResponseMessage response = await client.PostAsync("/api/orders", null);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}
dotnet test
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 138 ms - Bookstore.TokenLab.Tests.dll (net10.0)

İkincisi tarayıcı tarafında: login'den sonra JavaScript'in erişebildiği yerlerde credential kalmadığını gerçek Chrome'da doğruluyor. Aynı script local modunda başarısız oldu, yani test gerçekten bir şey yakalıyor:

import { chromium } from 'playwright-core';

const browser = await chromium.launch({
  executablePath: process.env.CHROME,
  headless: true,
  args: ['--host-resolver-rules=MAP *.bookstore.test 127.0.0.1'],
});
const page = await browser.newPage({ ignoreHTTPSErrors: true });
await page.goto('https://shop.bookstore.test:19402/?mode=cookie');
await page.waitForSelector('#status:text("ready")');

// Login'den sonra JavaScript'in erişebildiği hiçbir yerde credential olmamalı
const exposed = await page.evaluate(() => ({
  localStorage: Object.keys(localStorage),
  sessionStorage: Object.keys(sessionStorage),
  cookie: document.cookie,
}));
const ok = exposed.localStorage.length === 0 && exposed.sessionStorage.length === 0 && !exposed.cookie.includes('session');
console.log(JSON.stringify(exposed));
console.log(ok ? 'PASS: no credential reachable from JavaScript' : 'FAIL: credential exposed to JavaScript');
await browser.close();
process.exit(ok ? 0 : 1);
node check05.mjs
{"localStorage":[],"sessionStorage":[],"cookie":""}
PASS: no credential reachable from JavaScript

# aynı kontrol mode=local ile
{"localStorage":["bookstore.accessToken"],"sessionStorage":[],"cookie":""}
FAIL: credential exposed to JavaScript

BFF'in YARP (Yet Another Reverse Proxy) ile tam kurulumu, OpenID Connect akışı ve Angular tarafındaki authentication state ilerideki "Authentication ve Authorization" serisinde. Burada yalnız karar ve doğrulaması var.

BFF, XSS'in token okumasını engelliyor ama başka bir tehdit kalıyor: kullanıcının makinesindeki bir malware'in cookie dosyasını kopyalayıp session'ı başka bir makinede kullanması. DBSC (Device Bound Session Credentials) buna karşı geliştirilen bir web standardı önerisi. Tarayıcı her session için donanımda, örneğin Windows'ta TPM (Trusted Platform Module) içinde, dışarı aktarılamayan bir key çifti üretiyor; sunucu kısa ömürlü cookie'yi yenilerken tarayıcıdan bu key'le kanıt istiyor. Kopyalanan cookie kısa sürede geçersizleşiyor ve key olmadan yenilenemiyor.

Google Nisan 2026'da DBSC'yi Chrome 146 ile Windows kullanıcılarına açtığını, macOS desteğinin sonraki bir sürümle geleceğini duyurdu; standart çalışması W3C (World Wide Web Consortium) Web Application Security Working Group'ta sürüyor. Şimdilik yalnız Chrome'da olan bir ek katman; Bookstore'un session tasarımını buna göre beklemeye almaya gerek yok, ama BFF session'ını kısa ömürlü cookie ve yenileme endpoint'iyle kurmak DBSC'ye geçişi kolaylaştırır.

Üretimde Dikkat

Session cookie'nin ömrünü RFC'nin önerdiği gibi refresh token'ın azami ömrüyle hizala; cookie 30 gün, refresh token 8 saat yaşıyorsa kullanıcı garip hatalar görür. Session store'u ölç: BFF'e geçince her kullanıcı için sunucuda token tutuyorsun, birden çok instance varsa bu store'un paylaşılması gerekir. Logout'ta yalnız cookie'yi silmek yetmez; sunucudaki session'ı ve refresh token'ı da iptal et.

Geçişi ölçerek yap. Bir süre localStorage'daki eski token'ı okumayı bırakmadan yeni cookie session'ı paralel çalıştırmak cazip gelir; bunu yaparsan eski key'i bir sonraki release'te localStorage.removeItem ile temizlemeyi unutma, yoksa çalınabilecek token tarayıcıda aylarca kalır. Lab'daki check05.mjs gibi bir kontrolü end-to-end test setine eklemek, birinin "küçük bir kolaylık" için token'ı tekrar storage'a yazmasını yakalar.

Sık Düşülen Tuzaklar

Birincisi, HttpOnly cookie'ye geçince XSS'in çözüldüğünü düşünmek. Lab'daki 1005 numaralı sipariş, token'ı hiç görmeyen bir script tarafından verildi. Cookie, hırsızlığı engeller; kullanıcı adına isteği engellemez.

İkincisi, cookie'nin içine access token koyup buna BFF demek. Cookie HttpOnly olsa bile token istemciye gidiyor; RFC, istemci tarafında tutulan session'larda cookie içeriğinin şifrelenmesini öneriyor. Sunucu tarafı bir session ID'si daha basit.

Üçüncüsü, memory'de tutmayı "güvenli" saymak. Okumayı zorlaştırır, ama dördüncü senaryoya ve yeni token alma senaryosuna karşı hiçbir şey yapmaz.

Dördüncüsü, refresh token'ı localStorage'a koymak. XSS'in hasar penceresi dakikalardan günlere çıkar.

Beşincisi, cookie'ye geçip CSRF'i unutmak. SameSite'ı açıkça yaz, durum değiştiren endpoint'lerde Fetch Metadata ya da antiforgery kontrolü yap.

Küçük Bir Deneme

★☆☆ Lab'da AuthStore'un local modunu sessionStorage'a çevir, probe'u çalıştır ve sonucun değişip değişmediğine bak. Sonra uygulamayı ikinci bir sekmede açıp oradaki script'in token'ı görüp görmediğini kontrol et.

★★☆ check05.mjs'yi document.cookie'nin yanında IndexedDB veritabanlarını da listeleyecek şekilde genişlet ve cookie modunda hâlâ başarılı geçtiğini doğrula.

★★★ Session cookie'yi 5 dakikalık ömürle bırakıp /api/session/refresh adında bir yenileme endpoint'i ekle; süresi dolan session ile gelen /api/orders isteğinin 401 aldığını ve yenilemeden sonra 201 aldığını bir xUnit testiyle göster.

Bir sonraki yazıda XSS'in en sessiz kaynağına bakıyoruz: sayfaya kendi isteğinle eklediğin üçüncü parti script'ler ve iframe'ler. Ödeme, analytics ve chat widget'larının sayfadaki yetkisini, iframe sandbox attribute'larını, frame-ancestors ve Permissions-Policy'yi ve güvenli bir postMessage service'ini konuşacağız.