Guida
La tua chiave API LLM è probabilmente nel bundle del frontend
Perché le variabili VITE_ e NEXT_PUBLIC_ finiscono nel browser, come cercare in una build una chiave OpenAI o Anthropic esposta e la route lato server che lo risolve.
· 7 min di lettura · A cura del team vetkit


Chiedi a un assistente di aggiungere una chat alla tua app React. Installa l’SDK di OpenAI, legge la chiave da import.meta.env.VITE_OPENAI_API_KEY e la funzionalità va al primo colpo. Il problema è che va anche per chiunque apra gli strumenti per sviluppatori del browser, trovi la chiave nel tuo JavaScript e cominci a inviare richieste a tuo nome.
È uno degli errori più comuni che vediamo nei progetti sviluppati con l’aiuto dell’AI, ed è facile che sfugga in review perché non sembra esserci nulla di sbagliato. La chiave non è hard-coded. Sta in un file .env correttamente ignorato da git. Il codice la legge da una variabile d’ambiente, che è esattamente ciò che tutti consigliano di fare.
Come succede
Gli strumenti di build del frontend non hanno variabili d’ambiente a runtime. Il browser esegue un bundle statico. Quindi, quando il tuo codice legge import.meta.env.VITE_SOMETHING o process.env.NEXT_PUBLIC_SOMETHING, il bundler sostituisce quell’espressione con il valore letterale durante la build. Dopo la build la variabile non esiste più: resta solo una stringa in un file JavaScript che ogni visitatore scarica.
I framework sono chiari su questo punto. Next.js dice che le variabili pubbliche vengono inserite inline in tutto il JavaScript inviato al browser. Vite espone al codice client solo le variabili con il prefisso VITE_ proprio per evitare che le altre trapelino per errore. Il prefisso è il consenso esplicito. Quando un assistente sceglie VITE_OPENAI_API_KEY perché il nome senza prefisso non funzionava nel browser, ha dato quel consenso al posto tuo.
Esiste anche una variante più subdola. Alcuni template iniziali copiano la chiave nel bundle con l’opzione define di Vite, per esempio 'process.env.GEMINI_API_KEY': JSON.stringify(...) in vite.config.ts. La variabile non ha un prefisso pubblico, quindi sembra sicura, ma l’effetto è esattamente lo stesso.
Gli SDK ufficiali cercano di impedirlo. L’SDK Node di OpenAI si rifiuta di girare in un browser a meno che tu non passi dangerouslyAllowBrowser: true, e il suo README spiega perché: espone le tue credenziali segrete nel codice lato client. A volte gli assistenti aggiungono quel flag per far sparire un errore. Altri client, come chiamate HTTP generiche o SDK senza un controllo sul browser, non ti avvisano affatto.
Quali variabili sono pubbliche
Se una variabile inizia con uno di questi prefissi, dai per scontato che il suo valore sia leggibile da chiunque carichi la tua app:
VITE_in Vite (e nei framework basati su di esso)NEXT_PUBLIC_in Next.jsREACT_APP_in Create React AppEXPO_PUBLIC_in Expo e nelle app React Native costruite con ExpoVUE_APP_in Vue CLI eGATSBY_in GatsbyPUBLIC_in Astro e nei moduli env pubblici di SvelteKit
Questi prefissi vanno bene per valori pensati per essere pubblici, come l’ID di un sito di analytics o l’URL della tua API. Non vanno mai bene per il segreto di un provider: OpenAI, Anthropic, Google Gemini, Mistral, una chiave segreta di Stripe, una service role key di un database.
Controlla la tua build
Il test più rapido è fare la build dell’app e cercare nell’output il prefisso della chiave. Le chiavi di progetto OpenAI iniziano con sk-proj-, quelle Anthropic con sk-ant- e le chiavi API di Google con AIza.
npm run build# Vite writes to dist/, Next.js static chunks live in .next/static/grep -rEo "sk-(proj|ant)-[A-Za-z0-9_-]{8}|AIza[0-9A-Za-z_-]{8}" dist .next/static 2>/dev/nullSe il comando stampa qualcosa, la chiave è nel tuo bundle. Controlla un sito in produzione allo stesso modo: apri gli strumenti per sviluppatori, vai nella scheda Sources o Network e cerca negli script caricati. Se riesci a trovarla in trenta secondi, ci riesce anche uno script che scandaglia i siti cercando proprio questo.
La soluzione
Sposta la chiamata al provider su un server che controlli e fai in modo che il browser parli con quel server. In Next.js è un route handler. In una single-page app con Vite può essere una funzione serverless sullo stesso host. La chiave mantiene un nome senza prefisso, così il bundler non la inserisce mai inline.
import OpenAI from 'openai'; // Server-only: no NEXT_PUBLIC_ prefix, never shipped to the browser.const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(request: Request) { const user = await requireSignedInUser(request); // your auth check await rateLimit(user.id); // your limiter: per user, per minute const { message } = await request.json(); if (typeof message !== 'string' || message.length > 4000) { return Response.json({ error: 'Invalid message' }, { status: 400 }); } const response = await client.responses.create({ model: 'gpt-5-mini', input: message, max_output_tokens: 800, }); return Response.json({ reply: response.output_text });}I tre dettagli attorno alla chiamata contano quanto spostarla. Senza un controllo di autenticazione, la tua route diventa un proxy gratuito e anonimo verso il modello: la stessa falla, con qualche passaggio in più. Senza un rate limit, un singolo utente autenticato può comunque far lievitare la bolletta. Senza un limite sull’output, una singola richiesta può generare molti più token di quanti ne servano alla tua funzionalità.
Tieni anche il nome del modello e i limiti sul server
max_output_tokens, un visitatore può modificarli. Decidi questi valori lato server e accetta dal client solo il messaggio dell’utente.Se la chiave è già stata rilasciata
Rimuovere la variabile dal codice da solo non basta. Le vecchie build restano in cache nei browser e nelle CDN, e la chiave potrebbe essere già stata copiata. Considerala compromessa:
- Crea una nuova chiave nella dashboard del provider e revoca quella vecchia.
- Inserisci la nuova chiave solo nelle impostazioni d’ambiente lato server, con un nome senza prefisso pubblico.
- Controlla nella pagina di utilizzo del provider se c’è traffico che non riconosci e imposta un limite di spesa.
- Rifai il deploy, poi ripeti la ricerca nella build vista sopra per confermare che il nuovo bundle sia pulito.
Se la chiave è mai stata committata in git, ruotala anche se hai cancellato il file. La cronologia di git conserva il vecchio valore.
Intercettarla prima del rilascio
Questo errore è facile da eliminare con l’automazione. Un secret scanner intercetta le chiavi committate nel repository. Non può però vedere una chiave che sta in un file .env ignorato, quindi ti serve anche un controllo che legga il codice e si accorga di una variabile pubblica passata a un client LLM.
Questo secondo schema è una delle regole di sicurezza per LLM e agent nelle scansioni complete di vetkit: segnala un valore proveniente da una variabile d’ambiente lato client (per esempio import.meta.env.VITE_* o process.env.NEXT_PUBLIC_*) che arriva a un client SDK di un LLM nel codice del browser, e spiega come spostare la chiamata lato server. Il Security Quick Scan gratuito di vetkit copre segreti committati, dipendenze con vulnerabilità note e postura di sicurezza di base. Nessuno dei due sostituisce una rapida occhiata all’output della tua build, che è la prova più diretta che puoi ottenere.