TEKNİK YAZI / Developer Tools ve Açık Kaynak

Frontend'de API Anahtarı Gizlenmez: VITE_, NEXT_PUBLIC_ ve CI Taraması

VITE_ ve NEXT_PUBLIC_ değişkenleri build sırasında pakete yazılır. API anahtarının frontend'e nasıl sızdığını ve CI'da nasıl yakalanacağını deneyle gösterir.

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

.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:

  1. Vite, .env dosyalarını ve build sürecinin ortam değişkenlerini okur.
  2. Adı VITE_ ile başlayan değişkenleri istemci koduna açar. Öneksiz değişkenler istemciye verilmez.
  3. Kodda import.meta.env.VITE_LLM_API_KEY yazan her yer, build sırasında değerin kendisiyle değiştirilir.
  4. 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.

Solda .env dosyası ve kaynak kod, sağda build çıktısı: VITE_ önekli anahtar paketin içinde metin olarak duruyor, öneksiz DB_PASSWORD yerine void 0 yazılmış; paket CDN'den her ziyaretçiye, eski deploy adreslerine ve arşivlere ulaşıyor
Şekil 1. Önekli değişken build sırasında pakete yazılır; paket yayına çıktıktan sonra değer de herkese açıktır.

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:

# 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:

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,
Dört taramanın iki değer için sonucu: depo taraması ikisini de göremedi, varsayılan gitleaks taraması Anthropic biçimli anahtarı yakalayıp kurgusal biçimi kaçırdı, özel kural ve değer taraması ikisini de yakaladı
Şekil 2. Aynı pakete dört tarama. Depo taraması temiz göründü; desen taraması yalnız tanıdığı biçimi buldu.

Ö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.

Kod yalnız API_URL değişkenini okurken define ile tanımlanan process.env nesnesi pakete altı değişkenle başlatılan build'de 8, geliştirme makinesinde 98 değişken taşıdı; DB_PASSWORD ve DEPLOY_TOKEN dahil
Şekil 3. Kodun ihtiyacı tek değişkendi; pakete build ortamının tamamı girdi.

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çılanSunucuda kalanNot
ViteVITE_ önekli (envPrefix ile değişir)Öneksiz değişkenlerenvPrefix: "" hata verir
Next.jsNEXT_PUBLIC_ önekliÖneksiz değişkenlerDeğer next build sırasında dondurulur
AstroPUBLIC_ önekli; astro:env şemasında access: "public"astro:env şemasında "secret"Gizli istemci değişkeni desteklenmez
SvelteKit 3Yapılandırmada public: true işaretli, $app/env/publicGeri kalanı, $app/env/privateEski $env/* modülleri kullanımdan kalktı
Nuxt 4runtimeConfig.publicruntimeConfig kökündeki anahtarlarNUXT_ ve NUXT_PUBLIC_ önekli ortam değişkenleri değerleri ezer
ExpoEXPO_PUBLIC_ önekliUygulamaya hiç girmemeliBelgeye 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:

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?

AnahtarTarayıcıdaNeden
Stripe publishable (pk_)EvetÖdeme bilgisinden token ya da PaymentMethod üretir; tahsilat yapamaz, hesap verisi okuyamaz
Stripe secret (sk_), restricted (rk_)HayırStripe 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çıksaErişimi Row Level Security politikaları sınırlar
Supabase secret (eski adı service_role)HayırBütün RLS politikalarını atlar
Firebase web yapılandırması (apiKey)EvetProjeyi tanımlar; verileri Security Rules, API’leri App Check korur
Google Maps JavaScript anahtarıEvet, kısıtlıysaWebsites (referrer) kısıtı ve yalnız kullanılan API’lere izin şart
Sentry DSNEvetYalnız yeni olay gönderir, okuma yetkisi yoktur
OpenAI, Anthropic, Gemini API anahtarlarıHayırHer 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.

Solda tarayıcıda durabilen anahtarlar: Stripe pk, Supabase publishable, Firebase web yapılandırması, Google Maps anahtarı, Sentry DSN; sağda asla tarayıcıya girmemesi gerekenler: Stripe sk ve rk, Supabase secret ve service_role, OpenAI, Anthropic ve Gemini anahtarları, AWS erişim anahtarı, veritabanı parolası
Şekil 4. Tarayıcıda durabilen anahtarın yetkisi sınırlıdır ve yine de kısıtlanır. Diğerleri sızarsa iptal edilir.

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:

  1. Kimlik: Oturumu olmayan istek sağlayıcıya hiç gitmez.
  2. Sınır: Kullanıcı başına istek ya da token sınırı uygulanır.
  3. Sabit parametreler: Model, max_tokens ve sistem istemi sunucuda belirlenir. İstemciden yalnız mesaj metni alınır.
  4. Hata maskeleme: Sağlayıcının hesap, kota ve limit hataları istemciye aynen taşınmaz.
Yanlış mimaride tarayıcı sağlayıcıya anahtarla doğrudan bağlanıyor; doğru mimaride tarayıcı oturum çereziyle kendi API'nize gidiyor, API oturum, sınır, sabit model ve bütçe kontrolünden sonra sağlayıcıya sunucudaki anahtarla bağlanıyor; gerçek zamanlı ses için sunucu kısa ömürlü token üretiyor
Şekil 5. Anahtar sunucuda kalır; tarayıcı yalnız kendi API'nizi tanır.

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.

Sızıntıyı CI’da yakalamak

Tek bir tarama bütün yolları kapatmaz. Her katman farklı bir şeyi görür.

Commit, push, CI depo taraması, CI build çıktısı taraması ve yayın aşamalarından oluşan kontrol hattı; her aşamanın neyi yakaladığı ve neyi kaçırdığı yanında yazıyor
Şekil 6. Depoya bakan katmanlar build'de eklenen değeri görmez. Eksik halka build çıktısının taranmasıdı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:

# .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ı

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:

  1. Ö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.
  2. Kullanımı kontrol edin: Sağlayıcının kullanım ve fatura kayıtlarında beklenmeyen istekleri arayın.
  3. 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.
  4. 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.
  5. 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

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