Next.js'te Özel Sayfa Koruma: Noindex Yetmez, Server Tarafında Kilitle
Linki bilenlerin görebileceği sayfalar ile gerçekten şifreli alanlar farklı şeylerdir. Next.js App Router'da noindex, middleware ve HttpOnly cookie ile pratik bir koruma modeli.
Next.js'te Özel Sayfa Koruma: Noindex Yetmez, Server Tarafında Kilitle#
noindex arama motorlarına bir ricadır, erişim kontrolü değildir. Bir sayfayı gerçekten gizlemek istiyorsan içerik HTML'e hiç girmemeli; şifre doğrulaması server tarafında yapılmalı ve yetki durumu güvenli bir cookie ile tutulmalıdır.
Bu ayrımı netleştirmek önemli: Linke sahip olan herkesin görebileceği "saklı" sayfa başka, yalnızca şifreyle açılacak özel not başka bir güvenlik modelidir.
Üç farklı seviye var#
| Seviye | Ne sağlar? | Ne sağlamaz? |
|---|---|---|
| Menüden linklememek | Keşfedilebilirliği azaltır | URL bilen kişiyi engellemez |
noindex ve sitemap dışı bırakmak | Arama sonuçlarına girmemeyi hedefler | Erişim kontrolü değildir |
| Server-side şifre kontrolü | İçeriği yetkisiz kullanıcıya render etmez | Güçlü parola ve rate limit ihtiyacını ortadan kaldırmaz |
Bir sayfa özel veri içeriyorsa üçüncü seviyeye çıkmak gerekir. Aksi halde metin client bundle içinde, HTML içinde veya cache katmanında kalabilir.
Middleware kapıyı erken kapatır#
Next.js middleware, route'a gelmeden önce karar vermek için iyi bir yerdir. Örneğin bir sayfanın opsiyonel secret query parametresiyle açılmasını istiyorsan:
import { NextRequest, NextResponse } from 'next/server' const privatePaths = new Set(['/about/naz']) export function middleware(request: NextRequest) { const secret = process.env.PAGE_SECRET if ( secret && privatePaths.has(request.nextUrl.pathname) && request.nextUrl.searchParams.get('key') !== secret ) { return new NextResponse(null, { status: 404 }) } return NextResponse.next() }
Bu model "linki bilen görsün ama key varsa daha da kapansın" gibi hafif gizlilik senaryoları için yeterli olabilir. Fakat şifreli not gibi içeriklerde form doğrulamasını ayrıca server action tarafına almak daha sağlıklıdır.
Server action ile şifreyi doğrula#
Client tarafında if (password === '...') yazmak güvenlik değildir. Şifre ve özel içerik server environment içinde durmalı, doğru girişten sonra HttpOnly cookie set edilmelidir.
'use server' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' export async function grantPrivateNote(formData: FormData) { const password = String(formData.get('password') || '') if (password !== process.env.PRIVATE_NOTE_PASSWORD) { redirect('/private-note?state=wrong') } cookies().set('private_note_access', 'signed-token', { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 7, }) redirect('/private-note') }
Üretimde token değerini düz string yapmak yerine HMAC ile imzalamak daha doğru olur. Böylece kullanıcı cookie değerini tahmin ederek yetki kazanamaz.
Özel metni client bundle'a koyma#
En kritik kural bu: Gizli metin React state'inde, client component içinde veya statik JSON dosyasında durmamalı. Sayfa kilitliyken özel metin response'a hiç girmemeli.
Doğru desen:
export default function PrivateNotePage() { const hasAccess = checkSignedCookie() if (!hasAccess) { return <PasswordForm /> } return <PrivateNote paragraphs={getNoteFromServerEnv()} /> }
Bu yaklaşımda şifre yoksa kullanıcı sadece formu görür. Özel metin HTML'de, hydration payload'ında veya JavaScript bundle'ında bulunmaz.
SEO tarafını da kapat#
Şifreli sayfaların indekslenmesini istemiyorsan üç noktayı birlikte kullan:
- Sayfa metadata'sında
robots: { index: false, follow: false } - Response header'da
X-Robots-Tag: noindex, nofollow robots.txtiçinde ilgili path'i disallow etmek
Bu üçlü erişim kontrolü değildir ama arama motorları ve crawler davranışı için iyi bir hijyen sağlar.
Kısa sonuç#
Gizli sayfa tasarlarken karar basit: İçeriğin başkası tarafından görülmesi sorun değilse link-only yeterli olabilir. İçerik gerçekten kişiselse server-side kontrol kullan. noindex görünürlüğü azaltır; şifreli server render ise içeriği gerçekten saklar.
Güven Sınırı Nerede#
istemci --> middleware --> server component --> server action --> cookie | | | path eşleştirme session doğrulama HMAC ile imzalı çerez
Middleware erken karar verir; server component içeriği render etmeden önce oturumu tekrar doğrular; server action tek yazma noktasıdır. Üçü de aynı sırra bakar, ama hiçbiri sırra client'tan ulaşmasın diye kod yazmaz.
Komutlarla Doğrulama#
# Cookie'siz istek: özel metin HTML'de olmamalı curl -s http://localhost:3000/private-note | grep -c "gizli metin" # 0
# Geçersiz imzalı cookie ile istek: yine 0 curl -s -H "Cookie: private_note_access=rastgele" \ http://localhost:3000/private-note | grep -c "gizli metin"
# Yanlış şifre sonrası yönlendirme curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \ -X POST http://localhost:3000/private-note
Üretim doğrulaması yerel geliştirmeden farklıdır. NODE_ENV=production ile alınan HTML'de sır yoksa ama dev sunucusunda varsa, sebep büyük ihtimalle client bundle'a düşen bir import'tur.
HMAC İmzalı Cookie#
Düz true cookie'si herkes tarafından uydurulabilir. Değer, sunucu tarafı secret ile imzalanmalıdır:
import { createHmac } from 'crypto' function sign(value: string) { return createHmac('sha256', process.env.COOKIE_SECRET!) .update(value) .digest('hex') } cookies().set('private_note_access', sign('ok'), { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 7, })
Doğrulama tarafında imzalı ve beklenen değeri karşılaştırırken sabit zamanlı karşılaştırma kullan. Timing farkı, üretimde ölçülmesi zor ama var olan bir kaçış kapısıdır.
Önbellek Katmanı#
Özel sayfalar dinamik olmalıdır. Route cookie, next/headers veya sunucu secret'ı okuyorsa Next.js genelde dinamik işaretler; yine de doğrula:
- Cookie'siz aç: özel metin HTML'de yok.
- Geçerli cookie ile aç: metin var.
- Üretimde response header'larına bak, sadece yerelde değil.
- Sayfanın sitemap çıktısında olmadığını kontrol et.
Bir route yanlışlıkla statik üretildiyse sayfa içeriği build çıktısında düz metin olarak yatar. export const dynamic = 'force-dynamic' ve export const revalidate = 0 bu sınıf hatayı görünür kılar.
Sınırlar#
- Middleware tek başına yetmez: matcher dışı path, URL kodlama veya trailing slash varyantı kapıyı atlayabilir. Server component içindeki doğrulama ikinci savunma hattıdır.
noindex,robots.txtveX-Robots-Tagkeşfedilebilirliği azaltır; erişim kontrolü değildir.- Şifre denemelerini yavaşlatmak için rate limit ve gecikme gerekir. Aksi halde şifre, sunucu tarafında olsa bile brute force'a açıktır.
- Çerez ömrü uzadıkça çalıntı senaryosu büyür. Kısa ömür artı yenileme, "bir kere giriş, sonsuza kadar erişim" modelinden iyidir.
Tespit#
| Sinyal | Anlamı |
|---|---|
Aynı IP'den çok sayıda state=wrong | Şifre denemesi |
| HTML'de beklenmedik şekilde sırrın görünmesi | Yanlış render yolu veya statik önbellek |
| Geçersiz imzayla 200 dönmesi | İmza doğrulaması eksik |
Bu sinyalleri erişim logu ve uygulama logu üzerinden takip et. Özel sayfada tek satır kod değişikliği bile, sırrın HTML'e karışıp karışmadığını değiştirebilir; bu yüzden her deploy'da yukarıdaki curl kontrolleri çalıştırılmalıdır.
Cikarilar#
noindexgörünürlüğü azaltır, erişimi engellemez.- Sır client bundle'a girmemeli; yoksa istemciye hiç gönderilmemeli.
- Cookie değeri HMAC ile imzalı olmalı, düz bayrak olmamalı.
- Middleware ile server component doğrulamasını iki ayrı hat olarak düşün.
- Her deploy'da "sırrın HTML'de olmaması" testi bir komut olmalı.
Pratik Kontrol Listesi#
- Şifre sadece server action'da doğrulanıyor
- Cookie HttpOnly, SameSite=Strict, Secure ve imzalı
- Sayfa dinamik render ediliyor
-
robotsmetadata,X-Robots-Tag,robots.txtbirlikte - Hatalı şifrede sabit gecikme ve rate limit var
Bu liste tamamlanmadan "özel sayfa" iddiası tamamlanmış sayılmaz. Her maddenin koymadığı, bir yerde açık kapıdır; her kapının arkasında da aynı soru durur: sır, istemciye hiç ulaşıyor mu?
Middleware Eşleştirmesi#
Matcher geniş tutulursa public sayfalar da secret ister; dar tutulursa özel sayfa korumasız kalır. Path listesini merkezi bir sabitte tut ve her yeni route eklendiğinde eşleşmeyi test et:
export const config = { matcher: ['/private-note/:path*', '/about/naz'], }
Trailing slash ve URL kodlama varyantları için middleware içinde path'i normalize et. /private-note/ ve /private%2dnote farklı string'lerdir ama aynı dosyaya çıkarlar.
Hata ve Yönlendirme Davranışı#
Yanlış şifrede sessiz yeniden deneme yerine belirgin bir durum döndür:
redirect('/private-note?state=wrong')
Doğru girişte tek kullanımlık bir yanıt, ardından kalıcı sayfaya yönlendirme. Açık yönlendirmeden kaçınmak için redirect hedefini sabit tut, query parametresinden üretme. Aksi halde saldırgan, form sonrası kullanıcıyı kendi sitesine taşıyabilir.
Loglama#
Başarılı ve başarısız şifre denemelerini sayaç olarak tut; cookie değerini veya şifreyi asla loglama. Bir IP'den dakikada onlarca deneme, rate limit eşiğini otomatik tetiklemelidir. Loglarda görünen tek şey event=private_note_access, result=ok|wrong, ip ve ts olmalı.
İki Katmanlı Savunma Örneği#
export default async function PrivateNotePage() { const store = await cookies() const token = store.get('private_note_access')?.value if (!token || !verify(token)) { return <PasswordForm /> } return <PrivateNote paragraphs={getNoteFromServerEnv()} /> }
Bu desende middleware ilk eşi, server component ikinci eşi, server action da tek yazma noktasıdır. Üç katman aynı kararı verir: içeriği ancak doğrulanmış çerez render eder.
Sır Saklama#
Gizli metin environment variable veya sunucu tarafı dosyada durmalı, asla repo içinde commit edilmemeli. process.env.PRIVATE_NOTE_TEXT gibi bir değer varken bile, o değerin client tarafına NEXT_PUBLIC_ önekiyle sızmadığını kontrol et. NEXT_PUBLIC_ öneki taşıyan her değişken build çıktısına gömülür.
Son Not#
Gizli sayfa tasarımında tek kural var: istemciye neyin gönderildiği, sunucuda neyin doğrulandığından önemlidir. HTML'nin içini, JS bundle'ı ve hydration payload'ını oku; sır oradaysa karar yanlış demektir.
Hydration Payload Kontrolü#
Server component içinde gizli metin render ediliyorsa o metin RSC payload'ında da görünür. Payload'ı kontrol etmek için sayfanın HTML kaynağını ve ?_rsc= isteklerini doğrudan incele. Sır bu ikisinde de yoksa istemciye gitmiyor demektir.
Şifre Politikası#
Zayıf şifre, sunucu tarafı kontrolle bile kırılgandır. Asgari uzunluk, env üzerinden yönetilen tek paylaşımlı secret ve düzenli rotasyon. Paylaşımlı bir sayfa için kullanıcı hesabı sistemi kurmak şart değildir; ama secret'ı kodda tutmak her zaman yanlıştır.
Dağıtım Kontrolü#
Build çıktısında .env dosyası, secret veya özel metin araması yap. Yanlış yapılandırmada bu değerler statik dosyalara karışır. Deploy öncesi tek komutluk bir kontrol, üretimde görülecek hatayı staging'de yakalar.
Ne Zaman Yeterli#
İçerik herkes tarafından bilinebilirse link-only yeter. İçerik yalnızca tek kişiye aitse üç katman da çalışmalı: middleware, server doğrulaması ve imzalı cookie. Sır, render başlamadan önce sunucuda kalmalı.
Karar orada netleşir: istemciye ne gidiyor, sunucuda ne doğrulanıyor.
Güvenlik, render edilenden değil, gönderilmeyenden başlar.
Kabul Kriteri#
- HTML ve RSC payload'ında sır yok
- Geçersiz cookie ile 200 dönmez
- Şifre hataları rate limit'e takılır
Bu üçü geçmiyorsa sayfa özel değil, yalnızca gözden uzaktır.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver