Frontend’de API anahtarı gizlenmez. Tarayıcıda çalışan koda giren her değer, sayfayı açan herkesin bilgisayarına da iner. .env dosyası, VITE_ ve NEXT_PUBLIC_ önekleri bu durumu değiştirmez. .env anahtarı yalnız git deposunun dışında tutar. Önekler ise değeri build sırasında JavaScript paketine metin olarak yazdırır. Anahtar paketteyse, sayfanın kaynağına bakan herkes onu okuyabilir ve aynı isteği kendi bilgisayarından atabilir.
Bu yazı bir sırrın frontend paketine hangi yollardan girdiğini gösteriyor: önekli değişkenler, define ile tanımlanan process.env ve sunucudan istemciye taşınan veriler. Ardından hangi anahtarların tarayıcıda durabileceğini, doğru mimariyi ve sızıntıyı CI’da yakalamanın yolunu anlatıyor. Deney bu yazı için Vite 5.4.11 ve gitleaks 8.30.1 ile, kurgusal anahtarlarla yapıldı. Framework ve sağlayıcı bilgileri 21 Ekim 2026 tarihli resmî belgelere dayanıyor.
Kısaca
.envdosyası sırrı gizlemez, yalnız git’e girmesini engeller. Kodda okunanVITE_,NEXT_PUBLIC_,PUBLIC_veEXPO_PUBLIC_önekli her değer build sırasında pakete metin olarak yazılır.- Anahtar git’e hiç girmese de pakette olabilir: CI ya da barındırma platformu değeri build anında ekler. Depoyu tarayan araç bunu göremez; build çıktısını da tarayın.
- Önek kullanmadan da sızdırabilirsiniz. Vite’ta
process.envnesnesinidefineile bütün hâlinde tanımlamak, build ortamındaki bütün değişkenleri pakete taşır. - Publishable anahtarlar (Stripe
pk_, Supabase publishable, Firebase yapılandırması) tarayıcıda durabilir. LLM, ödeme ve bulut anahtarları yalnız sunucuda durur. - Sızıntıda ilk iş anahtarı iptal etmektir. Kodu düzeltmek, eski build’leri kaldırmak ve git geçmişini temizlemek iptalin yerini tutmaz.
.env dosyası API anahtarını gizler mi?
Gizlemez. .env dosyasının işi yapılandırmayı kaynak koddan ayırmaktır. Dosya .gitignore içindeyse anahtar depoya girmez, o kadar. Tarayıcıda çalışacak koda giren değer ise build sırasında pakete yazılır.
Vite’ta süreç adım adım şöyle işler:
- Vite,
.envdosyalarını ve build sürecinin ortam değişkenlerini okur. - Adı
VITE_ile başlayan değişkenleri istemci koduna açar. Öneksiz değişkenler istemciye verilmez. - Kodda
import.meta.env.VITE_LLM_API_KEYyazan her yer, build sırasında değerin kendisiyle değiştirilir. - Sonuç, CDN’e yüklenen JavaScript dosyasında düz metin olarak durur.
Vite belgesi bunu açıkça yazar: VITE_* değişkenleri API anahtarı gibi hassas bilgi içermemelidir, çünkü build sırasında kaynak koda gömülürler. Next.js’te NEXT_PUBLIC_ öneki aynı işi yapar. Değer next build sırasında tarayıcıya giden JavaScript’e yazılır ve build’den sonra değişmez.
Minify ve obfuscation bu sonucu değiştirmez. Değişken adları kısalır ama değer aynı kalır ve tarayıcının geliştirici araçlarında aranabilir. Source map’i kapatmak da yalnız orijinal kodu gizler; değer derlenmiş dosyada zaten açıktadır.
Paket bir kez yayına çıktıktan sonra değer yalnız güncel sitede kalmaz. Barındırma platformları her deploy’a ayrı bir adres verebilir. Örneğin Vercel’de bu adresler varsayılan olarak herkese açıktır ve saklama politikası izin verdiği sürece erişilebilir kalır. CDN önbellekleri ve arşiv servisleri de eski dosyaların kopyasını tutabilir.
Deney: anahtar git’te yokken de pakette
Üretimdeki tipik durum şudur: .env dosyası doğru şekilde .gitignore içindedir, CI ya da barındırma platformu ise VITE_LLM_API_KEY değerini build anında ortam değişkeni olarak verir. Anahtar git geçmişinde hiç yoktur ama pakete yine girer.
Bu senaryo küçük bir Vite uygulamasıyla denendi. Uygulama, tarayıcıdan bir LLM sağlayıcısına anahtarla doğrudan istek atıyor; sık görülen yanlış örnek budur. Kullanılan değerlerin hepsi kurgusaldır:
VITE_LLM_API_KEY: Anthropic anahtar biçimine uyan rastgele bir değer.VITE_PAYMENTS_TOKEN:acme_live_ile başlayan, hayalî bir sağlayıcının biçiminde rastgele bir değer.DB_PASSWORDveDEPLOY_TOKEN: Sunucuya ait, öneksiz değişkenler.
# 1) Depo geçmişini tara: anahtar git'te değil
gitleaks git . --redact
# INF no leaks found
# 2) CI'daki gibi: değerler ortam değişkeninden gelir
VITE_LLM_API_KEY=... VITE_PAYMENTS_TOKEN=... DB_PASSWORD=... DEPLOY_TOKEN=... npx vite build
# 3) Build çıktısını tara
gitleaks dir dist --redact -v
# RuleID: anthropic-api-key
# File: dist/assets/index-CtAfWn4I.js
# WRN leaks found: 1
Sonuçlar:
- Depo taraması temiz çıktı. Bu doğru bir sonuçtur, çünkü anahtar git’e hiç girmedi.
- 1.353 baytlık paket iki önekli değeri de metin olarak içeriyordu.
DB_PASSWORDveDEPLOY_TOKENpakette yoktu; Vite öneksizDB_PASSWORDokumasının yerinevoid 0yazdı. - gitleaks build çıktısındaki Anthropic biçimli anahtarı
anthropic-api-keykuralıyla buldu,acme_live_değerini kaçırdı. Araç bu biçimi tanımıyor. Pakette değişken adı da tek harfe kısaldığı için, anahtar kelimelere bakan genel kural da tetiklenmedi. - Özel bir gitleaks kuralıyla ve değer taramasıyla iki değer de yakalandı. İki yöntem de aşağıdaki CI bölümünde anlatılıyor.
Paketin ilgili satırı şöyle görünüyor (değerler kısaltıldı):
const c="sk-ant-api03-…",a="acme_live_…",l=void 0;document.querySelector("#ask").addEventListener("click",async()=>{const s=await fetch("https://api.anthropic.com/v1/messages",{method:"POST",headers:{"x-api-key":c,
Önek olmadan da sızar: define ve process.env
Create React App’ten Vite’a geçen projelerde sık görülen bir kalıp, eski process.env kullanımını bozmamak için bütün ortamı define ile tanımlamaktır:
// vite.config.js: YANLIŞ
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); // "": önek filtresi yok
return { define: { "process.env": env } };
});
loadEnv üçüncü argümanı boş dize olduğunda yalnız .env dosyalarını okumaz; build sürecindeki bütün ortam değişkenlerini de döndürür. define bu nesneyi JSON olarak koda yazar. Uygulama yalnız process.env.API_URL okusa bile nesnenin tamamı pakete girer.
Deneyde yalnız 6 değişkenle başlatılan bir build’de pakete 8 değişken girdi; fazladan ikisini Vite ve işletim sistemi ekledi. Bunların arasında DB_PASSWORD ve DEPLOY_TOKEN değerleri de vardı. gitleaks bu iki değeri genel kuralıyla yakaladı, çünkü değişken adları nesnenin içinde olduğu gibi kaldı. Aynı yapılandırma bir geliştirme makinesinde 98 değişkeni pakete taşıdı.
Vercel gibi platformlarda proje ortam değişkenleri build adımında da okunabilir. Bu kalıp orada projenin bütün sırlarını pakete taşıyabilir.
Vite boş öneki bilerek engeller: envPrefix: "" verildiğinde build, hassas bilgi ifşası uyarısıyla durur. define bu korumanın dışında kalır. Güncel Vite 8 kaynak kodunda loadEnv aynı şekilde çalışıyor ve define belgesi nesne değerlerinin JSON.stringify ile koda yazıldığını söylüyor.
Doğrusu, istemciye yalnız gereken değeri tek tek tanımlamaktır:
// vite.config.js: istemciye yalnız gereken değer
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
define: {
// Config dosyasında okunur, pakete yalnız bu değer yazılır
__API_URL__: JSON.stringify(env.API_URL),
},
};
});
Bu yapılandırmayla aynı build tekrarlandığında pakette yalnız API_URL değeri vardı. Sunucu sırlarının yerine verilen rastgele değerlerin hiçbiri pakete girmedi.
Framework’lerde istemciye açılan değişkenler
Her framework istemciye açılacak değişkenleri farklı bir kuralla seçer. Ortak nokta şudur: istemciye açılan değer, derlenmiş koda yazılır ve herkese açıktır.
| Framework | İstemciye açılan | Sunucuda kalan | Not |
|---|---|---|---|
| Vite | VITE_ önekli (envPrefix ile değişir) | Öneksiz değişkenler | envPrefix: "" hata verir |
| Next.js | NEXT_PUBLIC_ önekli | Öneksiz değişkenler | Değer next build sırasında dondurulur |
| Astro | PUBLIC_ önekli; astro:env şemasında access: "public" | astro:env şemasında "secret" | Gizli istemci değişkeni desteklenmez |
| SvelteKit 3 | Yapılandırmada public: true işaretli, $app/env/public | Geri kalanı, $app/env/private | Eski $env/* modülleri kullanımdan kalktı |
| Nuxt 4 | runtimeConfig.public | runtimeConfig kökündeki anahtarlar | NUXT_ ve NUXT_PUBLIC_ önekli ortam değişkenleri değerleri ezer |
| Expo | EXPO_PUBLIC_ önekli | Uygulamaya hiç girmemeli | Belgeye göre derlenmiş uygulamada düz metin görünür |
Mobil uygulama da bir istemcidir. Expo belgesi EXPO_PUBLIC_ değişkenlerine özel anahtar konmamasını, bu değerlerin derlenmiş uygulamada düz metin olarak görüneceğini yazar.
Next.js’te önek olmadan sızıntı
App Router’da Server Component’ler sunucuda çalışır ve sırları okuyabilir. Ama bir Server Component’in Client Component’e prop olarak geçirdiği her değer tarayıcıya serileştirilir. Next.js’in veri güvenliği belgesindeki örnekte veritabanı satırının tamamı prop olarak geçirilir ve bütün alanlar istemciye açılır. process.env üzerinden okunan bir değer de aynı yoldan tarayıcıya gidebilir.
Next.js belgesi üç önlem öneriyor:
- Sunucuya özel veri katmanı: Sırlara ve veritabanına yalnız bu katman dokunur. Katman
import "server-only"ile işaretlenir; bir istemci bileşeni bu modülü içe aktarırsa build hata verir. - Gereken kadar veri: İstemciye ham kayıt değil, yalnız ekranda gereken alanlar gider.
- Taint API’leri: React’in
experimental_taintUniqueValueveexperimental_taintObjectReferencefonksiyonları belirli bir değerin ya da nesnenin istemciye geçmesini engeller. Next.js’te hâlâ deneyseldir veexperimental.taintayarıyla açılır. Belge bunu filtrelemenin yerine değil, ek bir katman olarak önerir.
Hangi anahtarlar tarayıcıda durabilir?
Bazı anahtarlar herkese açık olacak şekilde tasarlanır. Yetkileri sınırlıdır; asıl koruma başka bir katmandadır. Bir anahtarın tarayıcıda durup duramayacağını anlamak için tek soru yeter: bu anahtarı ele geçiren biri ne yapabilir ve faturası kime çıkar?
| Anahtar | Tarayıcıda | Neden |
|---|---|---|
Stripe publishable (pk_) | Evet | Ödeme bilgisinden token ya da PaymentMethod üretir; tahsilat yapamaz, hesap verisi okuyamaz |
Stripe secret (sk_), restricted (rk_) | Hayır | Stripe yeni kullanımlar için secret yerine yetkisi daraltılmış restricted key öneriyor; ikisi de sunucuda kalır |
Supabase publishable (eski adı anon) | Evet, RLS açıksa | Erişimi Row Level Security politikaları sınırlar |
Supabase secret (eski adı service_role) | Hayır | Bütün RLS politikalarını atlar |
Firebase web yapılandırması (apiKey) | Evet | Projeyi tanımlar; verileri Security Rules, API’leri App Check korur |
| Google Maps JavaScript anahtarı | Evet, kısıtlıysa | Websites (referrer) kısıtı ve yalnız kullanılan API’lere izin şart |
| Sentry DSN | Evet | Yalnız yeni olay gönderir, okuma yetkisi yoktur |
| OpenAI, Anthropic, Gemini API anahtarları | Hayır | Her istek size faturalanır; sağlayıcılar tarayıcıda kullanılmamasını açıkça söyler |
Supabase, secret key için ek bir güvenlik ağı kurmuş: User-Agent başlığından tarayıcıdan geldiği anlaşılan istekleri 401 ile reddediyor. Belge bunun tam bir savunma olmadığını da yazıyor, çünkü sızan anahtar başka araçlardan kullanılabilir.
LLM anahtarlarında durum açıktır. OpenAI’nin API başvuru belgesi anahtarın tarayıcı ve uygulama gibi istemci tarafı kodda açığa çıkarılmamasını, sunucuda ortam değişkeninden ya da anahtar yönetim servisinden okunmasını söyler. Anthropic’in TypeScript SDK’sı tarayıcı desteğini varsayılan olarak kapatır; açmak için dangerouslyAllowBrowser: true vermeniz gerekir. Ayarın adı ne anlama geldiğini yeterince anlatıyor. Aynı ilke Google arama API’si yazısındaki anahtar için de geçerlidir.
“Herkese açık” Google anahtarları ve Gemini: Truffle Security’nin 25 Şubat 2026 tarihli araştırması bu tablonun bir satırını değiştirdi. Bir Google Cloud projesinde Gemini API (Generative Language API) açıldığında, aynı projedeki kısıtsız eski anahtarlar da Gemini uç noktalarını çağırabiliyor. Araştırmacılar Kasım 2025 Common Crawl verisinde web sitelerine gömülü, bu yolla kullanılabilen 2.863 canlı anahtar buldu. Bu anahtarlarla projenin Gemini’ye yüklediği dosyalara ve önbelleğe alınmış içeriğe erişmek ve projeye fatura çıkarmak mümkündü. Firebase belgesi de Gemini Developer API’nin herkese açık bir anahtarın izin listesine asla eklenmemesini söylüyor. Tarayıcıda duran Google anahtarını izin verilen API listesiyle sınırlayın; Gemini’yi ayrı ve sunucuda duran bir anahtarla çağırın.
Doğru mimari: anahtar sunucuda kalır
Tarayıcının sağlayıcıya doğrudan bağlanması gerekmiyorsa çözüm basittir. Tarayıcı kendi API’nize istek atar ve anahtar yalnız o API’nin çalıştığı sunucuda durur. Bu uç bir Next.js Route Handler, bir Astro endpoint’i, bir Cloudflare Worker ya da ayrı bir backend servisi olabilir.
Ama anahtarı sunucuya taşımak tek başına yetmez. Kimlik sormayan, herkese açık bir proxy anahtarın kendisi kadar tehlikelidir: kimse anahtarı görmez ama herkes onu sizin adınıza kullanır. Bu yüzden uç şu dört işi yapmalıdır:
- Kimlik: Oturumu olmayan istek sağlayıcıya hiç gitmez.
- Sınır: Kullanıcı başına istek ya da token sınırı uygulanır.
- Sabit parametreler: Model,
max_tokensve sistem istemi sunucuda belirlenir. İstemciden yalnız mesaj metni alınır. - Hata maskeleme: Sağlayıcının hesap, kota ve limit hataları istemciye aynen taşınmaz.
Aşağıdaki uç, Web standardı Request ve Response kullanır. Bu yüzden aynı fonksiyon Request alıp Response döndüren her ortamda çalışır: Next.js Route Handler’ında doğrudan, Astro endpoint’inde ({ request }) => handler(request) sarmalayıcısıyla, Cloudflare Worker’da fetch fonksiyonunun içinden. Oturum doğrulaması ve sınır sayacı dışarıdan verilir, çünkü ikisi de uygulamanıza özeldir.
// chat-handler.js: Tarayıcı bu uca istek atar; sağlayıcı anahtarı yalnız sunucuda durur.
const MODEL = "claude-haiku-5-5"; // model istemciden alınmaz
const MAX_TOKENS = 512;
const MAX_INPUT_CHARS = 4000;
export function createChatHandler({
apiKey,
getSessionUser,
rateLimiter,
fetchImpl = fetch,
}) {
if (!apiKey) throw new Error("Sağlayıcı anahtarı tanımlı değil");
return async function handle(request) {
// 1. Kimlik: oturumu olmayan istek sağlayıcıya hiç gitmez
const user = await getSessionUser(request);
if (!user) return json({ error: "unauthorized" }, 401);
// 2. Kullanıcı başına sınır: anahtar sızmasa da uç kötüye kullanılabilir
if (!(await rateLimiter.take(user.id))) {
return json({ error: "rate_limited" }, 429, { "retry-after": "60" });
}
// 3. Girdi: istemciden yalnız mesaj metni kabul edilir
const body = await request.json().catch(() => null);
const message =
typeof body?.message === "string" ? body.message.trim() : "";
if (!message || message.length > MAX_INPUT_CHARS) {
return json({ error: "invalid_message" }, 400);
}
const upstream = await fetchImpl("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: MODEL,
max_tokens: MAX_TOKENS,
messages: [{ role: "user", content: message }],
}),
});
if (!upstream.ok) {
// 4. Sağlayıcı hatasının ayrıntısı (hesap, kota, limit) istemciye taşınmaz
console.error("upstream_error", upstream.status);
return json({ error: "upstream_unavailable" }, 502);
}
const data = await upstream.json();
const text = (data.content ?? [])
.filter(block => block.type === "text")
.map(block => block.text)
.join("");
return json({ text });
};
}
function json(payload, status = 200, headers = {}) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
Next.js App Router’da bağlamak için birkaç satır yeter. Anahtarın adı öneksizdir, bu yüzden tarayıcı paketine yazılmaz. lib/chat-handler.js dosyasının başına import "server-only"; eklemek, bu modülün yanlışlıkla bir istemci bileşenine alınmasını build hatasına çevirir.
// app/api/chat/route.js
import { createChatHandler } from "@/lib/chat-handler";
import { getSessionUser, rateLimiter } from "@/lib/auth";
export const POST = createChatHandler({
apiKey: process.env.ANTHROPIC_API_KEY,
getSessionUser,
rateLimiter,
});
Uç sahte bir sağlayıcıyla altı senaryoda test edildi: oturumsuz istek, dolan sınır, bozuk ya da çok uzun gövde, istemcinin modeli ve max_tokens değerini değiştirme denemesi, sağlayıcı hatasının gizlenmesi ve tanımsız anahtar. Sunucusuz ortamda bellek içi sayaç örnekler arasında paylaşılmaz; sınır sayacı için Redis gibi ortak bir depo gerekir. Kullanıcı bütçesi, token ölçümü ve faturalama ayrıntıları LLM API gateway yazısında, sunucudaki sırrı dosyadan güvenle okumak ise systemd servis yazısındaki LoadCredential= bölümünde anlatılıyor.
Tarayıcının sağlayıcıya doğrudan bağlanması gerekiyorsa: Gerçek zamanlı ses gibi durumlarda gecikmeyi azaltmak için tarayıcı sağlayıcıya doğrudan bağlanır. Bu durumda da kalıcı anahtar tarayıcıya verilmez; sunucu kısa ömürlü bir token üretir.
- OpenAI Realtime API: Sunucu
/v1/realtime/client_secretsuç noktasından geçici bir anahtar alır, tarayıcı WebRTC oturumunu bu anahtarla açar. Belge standart anahtarların yalnız sunucuda kullanılmasını söyler. - Gemini Live API: Ephemeral token varsayılan olarak yeni oturum başlatmak için 1 dakika, açılan bağlantıda mesaj göndermek için 30 dakika geçerlidir. Google belgesi bu token’ların da istemciden çıkarılabileceğini açıkça yazar; avantajı kısa ömrüdür.
Sızıntıyı CI’da yakalamak
Tek bir tarama bütün yolları kapatmaz. Her katman farklı bir şeyi görür.
1. Commit öncesi: gitleaks’in pre-commit kancası staged değişiklikleri commit’ten önce tarar. Geliştirici kancayı SKIP=gitleaks ile atlayabildiği için tek savunma olamaz.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
2. Push: GitHub push protection, herkese açık depolarda kullanıcılar için varsayılan olarak açıktır. Özel depolarda GitHub Secret Protection gerektirir ve varsayılan olarak kapalıdır. Anthropic ve OpenAI API anahtarları, Stripe API anahtarları ve Supabase secret key biçimi hem push protection hem de partner programı kapsamındadır. Anthropic, herkese açık bir depoda bulunan anahtarı GitHub’ın bildirimi üzerine otomatik olarak devre dışı bırakır ve hesap sahibine e-posta gönderir. Google API anahtarları partner programındadır ama push protection kapsamında değildir. Bu korumaların hepsi depoya bakar, CDN’deki paketi görmez.
3. CI’da depo ve build çıktısı: Asıl eksik halka budur. Build’i CI’da çalıştırın ve çıktıyı iki yolla tarayın:
- Desen taraması:
gitleaks dir distbilinen sağlayıcı biçimlerini bulur. Kendi anahtar biçimleriniz için.gitleaks.tomldosyasına kural ekleyin. - Değer taraması: Sunucuya ait sırların değerlerini doğrudan arar. Biçime bakmadığı için bilinmeyen biçimleri de yakalar.
# .gitleaks.toml: varsayılan kurallar ve kendi anahtar biçiminiz
[extend]
useDefault = true
[[rules]]
id = "acme-live-key"
description = "ACME canlı API anahtarı"
regex = '''acme_live_[A-Za-z0-9]{32}'''
keywords = ["acme_live_"]
Değer taraması için gerçek sırları CI’a vermeniz gerekmez. Aynı adlarla rastgele “kanarya” değerler üretip build’i onlarla çalıştırmak yeter. Yapılandırma sırları pakete taşıyorsa kanaryalar da pakete girer. Deneyde yanlış define yapılandırmasıyla üç kanaryanın üçü de pakette bulundu; doğru yapılandırmada hiçbiri bulunmadı.
// scripts/scan-build-secrets.mjs: Build çıktısında sunucu sırlarının değerlerini arar.
// Kullanım: SERVER_SECRET_NAMES="DB_PASSWORD,LLM_API_KEY" node scan-build-secrets.mjs dist
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
const MIN_LENGTH = 12; // kısa değerler ("true", "3000") yanlış alarm üretir
const dir = process.argv[2] ?? "dist";
const names = (process.env.SERVER_SECRET_NAMES ?? "")
.split(",")
.map(name => name.trim())
.filter(Boolean);
const secrets = [];
for (const name of names) {
const value = process.env[name];
if (!value) {
console.warn(`atlandı: ${name} bu ortamda tanımlı değil`);
} else if (value.length < MIN_LENGTH) {
console.warn(`atlandı: ${name} ${MIN_LENGTH} karakterden kısa`);
} else {
secrets.push({ name, needle: Buffer.from(value) });
}
}
async function* walk(path) {
for (const entry of await readdir(path, { withFileTypes: true })) {
const child = join(path, entry.name);
if (entry.isDirectory()) yield* walk(child);
else if (entry.isFile()) yield child;
}
}
let leaks = 0;
for await (const file of walk(dir)) {
const content = await readFile(file); // Buffer: .map ve ikili dosyalarda da çalışır
for (const { name, needle } of secrets) {
// Değer hiçbir zaman yazdırılmaz; yalnız adı ve dosya
if (content.includes(needle)) {
console.error(`SIZINTI ${name} -> ${file}`);
leaks++;
}
}
}
if (leaks > 0) process.exit(1);
console.log(`${secrets.length} sır değeri ${dir} içinde arandı, bulunmadı.`);
Bu tarama yalnız değerin olduğu gibi geçtiği durumu yakalar; kodlanmış ya da şifrelenmiş hâlini görmez. Desen taraması bu boşluğu kısmen kapatır: gitleaks varsayılan olarak base64 gibi kodlamaları çözüp içini de tarar, ama yalnız tanıdığı biçimleri bulur.
Aşağıdaki GitHub Actions iş akışı üç taramayı bir araya getirir. gitleaks ikilisi doğrudan indirilir ve sağlama toplamı doğrulanır. Hazır gitleaks/gitleaks-action da kullanılabilir; güncel sürümü v3’tür ve organizasyon hesaplarında ücretsiz bir lisans anahtarı ister.
# .github/workflows/secret-scan.yml
name: secret-scan
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
env:
GITLEAKS_VERSION: 8.30.1
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # depo geçmişinin tamamı taransın
- name: gitleaks kur
run: |
base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
curl -sSfLO "${base}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfLO "${base}/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep "_linux_x64.tar.gz" "gitleaks_${GITLEAKS_VERSION}_checksums.txt" | sha256sum -c -
tar -xzf "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" gitleaks
- name: Depo geçmişini tara
run: ./gitleaks git . --redact --no-banner
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Sunucu sırları için kanarya değerler üret
run: |
for name in LLM_API_KEY DB_PASSWORD STRIPE_SECRET_KEY; do
echo "${name}=kanarya-$(openssl rand -hex 16)" >> "$GITHUB_ENV"
done
- name: Build
env:
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
run: npm run build
- name: Build çıktısını desenle tara
run: ./gitleaks dir dist --redact --no-banner
- name: Build çıktısında kanaryaları ara
env:
SERVER_SECRET_NAMES: LLM_API_KEY,DB_PASSWORD,STRIPE_SECRET_KEY
run: node scripts/scan-build-secrets.mjs dist
Next.js projesinde taranacak dizin dist değil, .next olur.
Depo geçmişinde daha önce iptal edilmiş eski bir sızıntı varsa gitleaks git her koşuda aynı bulguyu verir ve iş hep kırmızı kalır. Anahtarın iptal edildiğini doğruladıktan sonra o bulgunun parmak izini (Fingerprint) .gitleaksignore dosyasına ekleyin ya da eski bulguları içeren bir raporu --baseline-path ile verin. İki yöntemde de yalnız bilinen eski bulgu susar, yeni sızıntılar yakalanmaya devam eder.
gitleaks’in yazarı aracı özellik açısından tamamlanmış sayıyor; yeni sürümler yalnız güvenlik yaması içerecek. Aynı ekip Betterleaks adlı yeni bir tarayıcı geliştiriyor, v2 sürümü henüz geliştirme aşamasında. Örnek, yaygın kullanılan ve güvenlik yaması almaya devam eden gitleaks’in son sürümünü kullanıyor.
4. Yayına giden çıktı: Vercel gibi bir platform Git entegrasyonuyla kendi build’ini yapıyorsa, CI’ın taradığı paket yayına çıkan paket değildir. Platformun build ortamında CI’da olmayan değişkenler bulunabilir. Tam eşleşme için build’i CI’da vercel build ile yapın, .vercel/output dizinini tarayın ve aynı çıktıyı vercel deploy --prebuilt ile yayınlayın.
Diğer sızıntı yolları
- Yayına çıkan source map:
build.sourcemap: trueile üretilen.mapdosyası, orijinal kaynağı yorumlarıyla birlikte taşır. Denemede minify edilmiş pakette olmayan bir iç servis adresi, source map’teki yorum satırında duruyordu. Source map gerekiyorsa herkese açık sunucuya değil, hata izleme servisine yükleyin. - Ağa açık geliştirme sunucusu: Vite’ın geliştirme sunucusu
.envdosyalarınıserver.fs.denylistesiyle korur. 2025’te bu korumayı aşan en az altı güvenlik açığı yayımlandı ve bunlarvite --hostgibi ayarlarla ağa açılan sunucuları etkiliyordu. İlki GHSA-x574-m823-4x7w (CVE-2025-30208),?raw??sorgusuyla korumayı aşıyordu ve 4.5.10, 5.4.15, 6.0.12, 6.1.2 ve 6.2.3 sürümlerinde düzeltildi. Geliştirme sunucusunu güvenilmeyen ağlara açmayın ve Vite’ı güncel tutun.
Anahtar sızdıysa ne yapmalı?
Sızan anahtar kendiliğinden geçersiz olmaz. GitGuardian’ın 2026 raporuna göre herkese açık GitHub commit’lerinde 2025’te 28,65 milyon yeni sır bulundu; bu sayı bir önceki yıla göre yüzde 34 fazla. Yapay zekâ servislerine ait sırlar yüzde 81 artışla 1.275.105’e çıktı. 2022’de sızan sırlar Ocak 2026’da yeniden test edildiğinde yüzde 64’ten fazlası hâlâ geçerliydi.
Sıra önemlidir:
- Önce iptal edin: Anahtarı sağlayıcı panelinden iptal edin ya da döndürün (rotate). Stripe’ta döndürme sırasında eski anahtar 7 güne kadar çalışmaya devam edebilir; sızan anahtarda bitiş zamanı olarak “Now” seçilir. Anthropic de ele geçirilen anahtarın hemen iptal edilmesini önerir.
- Kullanımı kontrol edin: Sağlayıcının kullanım ve fatura kayıtlarında beklenmeyen istekleri arayın.
- Kodu düzeltip yeniden yayınlayın: Anahtarı sunucuya taşıyın. Vercel’de ortam değişkeni değişiklikleri eski deploy’lara uygulanmaz, yeni bir deploy gerekir.
- Eski yayınları kapatın: Eski deploy adresleri sızan paketi sunmaya devam edebilir. Deployment Protection ya da saklama politikasıyla erişimi kapatın. Anahtar iptal edildiyse bu adımın aciliyeti azalır ama iç adresler ve yorumlar gibi diğer bilgiler hâlâ okunabilir.
- Git geçmişini temizleyin: Anahtar commit’lendiyse geçmişi temizlemek yeni kopyaları önler, var olanları silmez. Bu adım iptalin yerini tutmaz.
Üretime almadan önce kontrol listesi
- Tarayıcıya giden hiçbir değişken (
VITE_,NEXT_PUBLIC_,PUBLIC_,EXPO_PUBLIC_) sır içermiyor. defineyalnız gereken değerleri tek tek tanımlıyor;process.envnesnesi bütün hâlinde tanımlanmıyor.- Server Component’lerden istemciye yalnız gereken alanlar gidiyor; sırlara dokunan modüller
server-onlyile işaretli. - Tarayıcıdaki herkese açık Google anahtarları site ve API listesiyle kısıtlı; Gemini ayrı, sunucudaki bir anahtarla çağrılıyor.
- LLM, ödeme ve bulut anahtarları yalnız sunucuda; proxy ucu kimlik, sınır ve sabit parametre uyguluyor.
- Gerçek zamanlı bağlantılar kalıcı anahtar değil, kısa ömürlü token kullanıyor.
- pre-commit kancası ve push protection açık.
- CI depo geçmişini ve build çıktısını tarıyor; kendi anahtar biçimleriniz için kural var.
- Build çıktısında sunucu sırlarının kanarya değerleri aranıyor.
- Yayına giden çıktı, taranan çıktıyla aynı; sızıntı planında ilk adım anahtarı iptal etmek.
Sonuç
Frontend’de gizli anahtar diye bir şey yoktur; tarayıcıya giden değer herkese açıktır. .env dosyası ve ortam değişkeni önekleri bu kuralı değiştirmez, yalnız değerin pakete hangi yoldan gireceğini belirler. Asıl risk, anahtarın git’e hiç girmeden build sırasında pakete eklenmesidir. Depoyu tarayan araçlar bu durumu görmez. Sağlam düzen üç parçadan oluşur: sırlar sunucuda kalır, tarayıcı yalnız kimlik ve sınır uygulayan kendi API’nizle konuşur, CI ise build çıktısını hem desenle hem de kanarya değerlerle tarar.
Birincil kaynaklar
- Vite: Env Variables and Modes
- Vite: Shared Options (define, envPrefix)
- Next.js: Environment Variables
- Next.js: Data Security
- React: experimental_taintUniqueValue
- Astro: Environment variables
- SvelteKit: Environment variables
- Nuxt: Runtime Config
- Expo: Environment variables
- OpenAI API Reference: Authentication
- OpenAI: Realtime API with WebRTC
- Anthropic TypeScript SDK
- Anthropic: API key best practices
- Gemini API: Ephemeral tokens
- Stripe: API keys
- Supabase: Understanding API keys
- Firebase: Learn about using and managing API keys
- Google Maps Platform: API security best practices
- Sentry: DSN Explainer
- Truffle Security: Google API Keys Weren’t Secrets. But then Gemini Changed the Rules
- GitHub: About push protection
- GitHub: Supported secret scanning patterns
- gitleaks
- gitleaks-action
- Betterleaks
- Vercel: Accessing Deployments through Generated URLs
- Vercel: Environment variables
- Vercel CLI: vercel build
- GitHub Advisory Database: GHSA-x574-m823-4x7w (Vite)
- GitGuardian: The State of Secrets Sprawl 2026