Kiedy prosisz AI, żeby „podłączyła Twoją aplikację do Stripe, OpenAI albo Supabase", często wkłada klucz prosto do kodu frontendu. Działa za pierwszym razem i właśnie dlatego nikt tego nie sprawdza. Problem jest prosty i bezwzględny: wszystko, co trafia do przeglądarki, jest publiczne. Nie da się ukryć tekstu, który przeglądarka musi przeczytać. Ten poradnik wyjaśnia, dlaczego tak się dzieje, jak atakujący znajduje klucz w minutę, które klucze są niebezpieczne, a które nie, jak samodzielnie ich szukać i co zrobić, jeśli któryś już wyciekł.
Dlaczego klucz ląduje w przeglądarce
Są trzy typowe drogi. Pierwsza to publiczny prefiks zmiennych środowiskowych: w Next.js wszystko, co zaczyna się od NEXT_PUBLIC_, jest wbudowywane w JavaScript klienta, tak samo jak VITE_ w Vite, NUXT_PUBLIC_ w Nuxt czy REACT_APP_ w Create React App. AI widzi, że „zmienna nie dociera", i dodaje prefiks, żeby dotarła. Druga to bezpośrednie wywołanie: asystent pisze fetch('https://api.openai.com/...') w komponencie interfejsu z kluczem w nagłówku, bo to najkrótszy przykład, jaki zna. Trzecia to współdzielony plik ze stałymi, importowany zarówno przez serwer, jak i klienta, w którym ktoś zostawił klucz „tymczasowo".
We wszystkich trzech przypadkach efekt jest ten sam: klucz podróżuje w pliku .js, który każdy może pobrać, i zostaje w cache'ach CDN, w historii przeglądarki Twoich użytkowników i w archiwach crawlerów, które przeczesują internet w poszukiwaniu dokładnie tego.
Jak atakujący widzi to w 60 sekund
- DevTools. Otwórz swoją stronę, wciśnij F12, przejdź do zakładki Sources, wciśnij Ctrl+Shift+F, żeby szukać we wszystkich plikach, i wpisz
sk_live,sk-ant,AKIA,service_rolealbo-----BEGIN. Jeśli się pojawi, on już to ma. - Źródło strony. Prawy przycisk, wyświetl źródło, i otwarcie każdego podlinkowanego pliku
.js. Bundle są zminifikowane, ale nie zaszyfrowane; wystarczy wyszukiwanie tekstu. - Source mapy. Jeśli publikujesz pliki
.map, oprócz kodu widzi oryginalne nazwy zmiennych i komentarze, łącznie z// TODO: przenieść na serwer. - Automatyczne skanery. Są boty, które przechodzą po nowych domenach, pobierają ich JavaScript i testują każdy wzorzec klucza u odpowiedniego dostawcy. Klucz AWS, który wyciekł w bundle'u, zostaje użyty w minuty, nie w dni.
Które klucze są niebezpieczne i co się dzieje, gdy wyciekną
- Stripe
sk_live_. Pozwala tworzyć obciążenia, zwroty i czytać dane klientów. To prawdziwe pieniądze. - OpenAI, Anthropic i podobne (
sk-…). Kto go ma, zużywa na Twoje konto. Faktury na tysiące euro za klucz w publicznym repozytorium to historia, która się powtarza. - AWS
AKIA…i Google Cloud. W zależności od uprawnień: dostęp do bucketów, baz danych i stawianie maszyn do kopania na Twój koszt. - Supabase
service_role. Całkowicie omija Row Level Security: pełny odczyt i zapis Twojej bazy danych. Najpoważniejszy ze wszystkich w aplikacjach z vibe codingu. - Konta serwisowe Firebase (JSON). Odpowiednik
service_role: pełna kontrola nad projektem. - Dostawcy e-maili i SMS-ów (Resend, SendGrid, Twilio). Wysyłanie wiadomości w Twoim imieniu, czyli phishing z Twojej domeny.
- Stringi połączenia z bazą danych z użytkownikiem i hasłem oraz sekrety JWT: mając sekret, podpisuje się sesje dowolnego użytkownika.
- Tokeny dostępu trzymane w
localStorage. To nie Twoje klucze, tylko Twoich użytkowników, a każdy XSS je odczytuje i kradnie.
Które klucze mogą trafić do klienta
Nie każdy klucz jest sekretem. Niektóre są zaprojektowane do życia w przeglądarce, o ile to, co za nimi stoi, jest dobrze skonfigurowane:
- Klucz
anonlubpublishableSupabase, chroniony politykami RLS każdej tabeli. - Klucz publiczny Stripe (
pk_live_), który służy tylko do rozpoczynania płatności, nie do ich pobierania. - Konfiguracja Firebase (apiKey, projectId…), chroniona regułami bezpieczeństwa, a najlepiej także App Check.
- Klucz Google Maps ograniczony do domeny w konsoli Google.
- Identyfikatory analityki i widżetów firm trzecich.
Zasada, żeby je rozróżnić: klucz może trafić do klienta, jeśli dostawca wprost nazywa go publicznym i jeśli nawet mając go, obcy nie może zrobić niczego, czego nie chcesz. W razie wątpliwości jest tajny.
Jak szukać ich samodzielnie
W zbudowanym bundle'u szukaj znanych wzorców. W Next.js lub Vite, po buildzie:
grep -rEo "sk_live_[0-9a-zA-Z]+|sk-ant-[0-9a-zA-Z-]+|AKIA[0-9A-Z]{16}|service_role|eyJhbGciOi[0-9a-zA-Z._-]+" .next/static dist 2>/dev/null | head
W repozytorium, zanim klucz trafi do bundle'a, narzędzia takie jak gitleaks czy trufflehog przechodzą całą historię, nie tylko ostatni commit. A w panelu hostingu sprawdź, które zmienne mają publiczny prefiks: każda z nich jest wysyłana do przeglądarki.
Trzy miejsca, w które ludzie nie patrzą: komponenty oznaczone 'use client', które importują współdzielony plik konfiguracyjny; parametry URL-a, w których czasem podróżuje token lądujący w logach i historii; oraz publiczne repozytoria na GitHubie, gdzie klucz może leżeć od miesięcy, nawet jeśli nie ma go już w aktualnym kodzie.
Jak to naprawić: klucz zostaje na serwerze
Poprawny wzorzec jest taki, że przeglądarka rozmawia z Twoim serwerem, a Twój serwer z dostawcą. Własny endpoint odbiera żądanie, sprawdza sesję użytkownika, stosuje limit użycia i wykonuje wywołanie z kluczem odczytanym ze zmiennej środowiskowej bez publicznego prefiksu. W Next.js:
// app/api/summarize/route.ts (tylko serwer)
import { getSession } from '@/lib/auth'
export async function POST(req: Request) {
const session = await getSession(req)
if (!session) return new Response('Unauthorized', { status: 401 })
const { text } = await req.json()
if (typeof text !== 'string' || text.length > 5000) {
return new Response('Bad request', { status: 400 })
}
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.OPENAI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: text }] }),
})
return new Response(r.body, { headers: { 'Content-Type': 'application/json' } })
}
Z Supabase ta sama idea: operacje wymagające service_role idą do Edge Function albo do Twojego backendu, nigdy do klienta. Z Firebase do Cloud Function. I w każdym przypadku zmienna środowiskowa nazywa się OPENAI_API_KEY, a nie NEXT_PUBLIC_OPENAI_API_KEY.
Dla tokenów sesji wybieraj ciasteczka HttpOnly zamiast localStorage: ciasteczka HttpOnly nie da się odczytać z JavaScriptu, więc XSS nie może go ukraść. Jeśli Twoja biblioteka domyślnie trzyma sesję w localStorage, jak robi to klient Supabase w przeglądarce, zrekompensuj to rygorystyczną CSP albo użyj wariantu serwerowego z ciasteczkami.
Jeśli już wyciekł: rotować, nie usuwać
Usunięcie klucza z kodu nic nie daje: jest już w cache'ach, w historii gita i prawdopodobnie w czyichś rękach. Poprawna kolejność:
- Utworzyć nowy klucz u dostawcy i wdrożyć go na serwerze.
- Unieważnić stary zaraz potem. W Stripe, OpenAI i AWS robi się to jednym kliknięciem w panelu.
- Przejrzeć użycie: rozliczenia, logi API i obciążenia z ostatnich dni. Jeśli doszło do nadużycia, otwórz zgłoszenie u dostawcy; czasem anulują opłaty.
- Ustawić limity wydatków i alerty u każdego dostawcy, który na to pozwala, żeby następnym razem bolało mniej.
- Jeśli klucz dawał dostęp do danych osobowych użytkowników, oceń, czy musisz to zgłosić zgodnie z RODO.
Czyszczenie historii gita narzędziami takimi jak BFG czy git filter-repo to dobra higiena, ale nie zastępuje rotacji.
Jak sprawdza to Scanaris
Scanaris pobiera JavaScript, który serwuje Twoja strona, tak jak zrobiłby to atakujący, i szuka wzorców kluczy popularnych dostawców, tokenów JWT, adresów webhooków i stringów połączenia. Ostrzega też, jeśli Twoje source mapy są dostępne i czy wrażliwe pliki leżą w zasięgu ręki. Każde znalezisko ma prompt do wklejenia w Twoje AI z dokładną poprawką: przenieść wywołanie na serwer i zrotować klucz. Jeśli włączysz monitoring, ostrzeże Cię, gdy nowa sesja vibe codingu znów wsadzi klucz do klienta. To część checklisty startowej i poradnika bezpieczeństwa aplikacji tworzonych przez AI.
Najczęściej zadawane pytania
Czy klucz anon Supabase jest sekretem?
Nie. Jest przeznaczony do przeglądarki i chroni go RLS każdej tabeli. Tajny jest service_role. Jeśli Twoje RLS jest złe, problemem nie jest klucz anon, tylko polityki.
A jeśli klucz jest ograniczony do domeny?
Ograniczenia po refererze (jak w Google Maps) zmniejszają nadużycia z innych stron, ale nie ze skryptów, które fałszują nagłówek. Nadają się do kluczy niskiego ryzyka, nie do kluczy, które wydają pieniądze albo czytają dane.
Czy wystarczy usunąć go z repozytorium?
Nie. Klucz, który był w publicznym bundle'u albo publicznym commicie, jest skompromitowany. Zawsze go rotuj, a potem wyczyść historię, jeśli chcesz.