Protección de claves de API y secretos en aplicaciones frontend
Protege claves de API en el frontend con proxies de backend, custodia de tokens, higiene de variables de entorno e inyección en tiempo de ejecución.

Todo secreto en el frontend es público
Todo lo que se envía al navegador puede leerse. La minificación, la ofuscación y los prefijos de variables de entorno no protegen los secretos. Si un valor llega al bundle del cliente, un atacante puede extraerlo simplemente abriendo las DevTools. Asumir esto como una limitación fundamental cambia por completo la forma en que diseñas el acceso a tus API.
El patrón de proxy de backend
El enfoque más fiable es no enviar nunca el secreto al cliente. Todas las llamadas a APIs de terceros deben pasar por tu propio backend, que es quien conserva el secreto del lado del servidor.
// ❌ API key in the frontend — visible in network tab and bundle
const response = await fetch(
`https://api.maps.example.com/geocode?key=sk_live_abc123&address=${address}`
);
// ✅ Backend proxy — secret stays on the server
// Frontend calls your API
const response = await fetch(`/api/geocode?address=${encodeURIComponent(address)}`);// Backend proxy route (Next.js API route example)
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const address = req.nextUrl.searchParams.get("address");
if (!address || address.length > 200) {
return NextResponse.json(
{ error: "Invalid address parameter" },
{ status: 400 }
);
}
// Secret never leaves the server
const apiKey = process.env.MAPS_API_KEY;
const result = await fetch(
`https://api.maps.example.com/geocode?key=${apiKey}&address=${encodeURIComponent(address)}`,
);
if (!result.ok) {
return NextResponse.json(
{ error: "Geocoding service unavailable" },
{ status: 502 }
);
}
const data = await result.json();
return NextResponse.json(data);
}Buenas prácticas con las variables de entorno
Convenciones de frameworks como NEXT_PUBLIC_ o VITE_ marcan con un prefijo las variables que terminan incluidas en el código del cliente. Malinterpretar esto filtra secretos directamente en los bundles de producción.
// ❌ Secret with public prefix — bundled into client JavaScript
// .env
// NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123
// ✅ Only publishable keys get the public prefix
// .env
// NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789 (safe for client)
// STRIPE_SECRET_KEY=sk_live_abc123 (server only)
// Validation at startup — catch misconfigured secrets early
function validateEnvironment(): void {
const publicVars = Object.keys(process.env).filter((key) =>
key.startsWith("NEXT_PUBLIC_")
);
const sensitivePatterns = [
/SECRET/i,
/PRIVATE/i,
/SK_LIVE/i,
/PASSWORD/i,
/TOKEN/i,
];
for (const varName of publicVars) {
for (const pattern of sensitivePatterns) {
if (pattern.test(varName)) {
throw new Error(
`Potentially sensitive variable "${varName}" has NEXT_PUBLIC_ prefix. ` +
`This will be exposed in the client bundle. ` +
`Remove the NEXT_PUBLIC_ prefix if this is a secret.`
);
}
}
}
}Alcance y rotación de tokens
Cuando un cliente necesita autenticarse directamente contra una API, usa tokens de corta duración y alcance limitado en lugar de claves de API de larga duración.
// ❌ Long-lived API key with full permissions
// const apiKey = "sk_live_full_access_forever";
// ✅ Short-lived, scoped token generated server-side
interface ScopedToken {
token: string;
expiresAt: number;
permissions: string[];
resourceRestrictions: Record<string, string>;
}
// Backend endpoint that generates scoped tokens for the client
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const scopedToken = await tokenService.create({
userId: session.userId,
permissions: ["read:own-files", "write:own-files"],
resourceRestrictions: {
bucket: `user-${session.userId}`,
},
expiresInSeconds: 900, // 15 minutes
});
return NextResponse.json({
token: scopedToken.token,
expiresAt: scopedToken.expiresAt,
});
}
// Client uses the scoped token for direct uploads
async function uploadFile(file: File): Promise<string> {
// Get a fresh scoped token from our backend
const { token, expiresAt } = await fetch("/api/upload-token", {
method: "POST",
}).then((r) => r.json());
if (Date.now() > expiresAt) {
throw new Error("Token expired before upload could start");
}
// Use scoped token to upload directly to storage
const response = await fetch("https://storage.example.com/upload", {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": file.type,
},
body: file,
});
return response.json().then((r) => r.url);
}Escaneo de secretos en Git
Los secretos que se confirman en el control de versiones permanecen en el historial de Git incluso después de eliminarlos. Evita que los commits con secretos lleguen siquiera a subirse.
// .pre-commit-config.yaml pattern for secret scanning
// Pre-commit hook that blocks secrets before they enter git history
interface SecretPattern {
name: string;
pattern: RegExp;
severity: "block" | "warn";
}
const secretPatterns: SecretPattern[] = [
{
name: "AWS Access Key",
pattern: /AKIA[0-9A-Z]{16}/,
severity: "block",
},
{
name: "Generic API Key",
pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]/i,
severity: "block",
},
{
name: "Private Key",
pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/,
severity: "block",
},
{
name: "Stripe Secret Key",
pattern: /sk_live_[a-zA-Z0-9]{20,}/,
severity: "block",
},
{
name: "JWT Secret",
pattern: /(?:jwt[_-]?secret)\s*[:=]\s*['"][^'"]{10,}['"]/i,
severity: "warn",
},
];
function scanForSecrets(
content: string,
filename: string
): { found: boolean; matches: string[] } {
const matches: string[] = [];
for (const { name, pattern, severity } of secretPatterns) {
if (pattern.test(content)) {
matches.push(`[${severity.toUpperCase()}] ${name} found in ${filename}`);
}
}
return { found: matches.length > 0, matches };
}Cabeceras de Content Security Policy
Incluso con proxies de backend, restringe a dónde puede hacer peticiones tu frontend. Las cabeceras CSP limitan el daño si un atacante logra inyectar código en tu página.
// next.config.ts — restrict API connections to known origins
const securityHeaders = [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
// Only allow API calls to your own backend and trusted CDNs
"connect-src 'self' https://api.yourdomain.com",
"img-src 'self' https://cdn.yourdomain.com data:",
"font-src 'self'",
"frame-src 'none'",
].join("; "),
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
];
// Apply to all routes
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};Puntos clave
Asume que el código frontend es público y diseña en consecuencia. Haz pasar todas las llamadas a API que involucren secretos por tu propio proxy de backend, de forma que el secreto nunca llegue a tocar el cliente. Usa los prefijos específicos de cada framework (NEXT_PUBLIC_, VITE_) de forma deliberada: ahí solo deben ir las claves publicables.
Cuando los clientes deban autenticarse directamente con APIs de terceros, genera tokens de alcance limitado y corta duración en el servidor, en lugar de repartir claves de larga duración. Escanea en busca de secretos en los hooks de pre-commit y en los pipelines de CI para evitar exposiciones accidentales en el historial de Git. Añade cabeceras de Content Security Policy para restringir a dónde puede hacer peticiones de red tu frontend: esto limita el radio de impacto si tu página se ve comprometida. La regla es simple: si perder una credencial causaría daño, esa credencial nunca debe existir en código accesible desde el cliente.


