You asked AI to add Stripe payments to your app. The generated code imported the Stripe SDKWhat is sdk?A pre-built library from a service provider that wraps their API into convenient functions you call in your code instead of writing raw HTTP requests. and initialized it with your secret key, right in the React component, shipped to every browser that loads the page. Or you asked for a "production-ready Express server" and got no security headers at all, no CSP, no HSTSWhat is hsts?HTTP Strict Transport Security - a response header that tells browsers to only connect to your site over HTTPS for a specified duration., no frame protection. AI treats security headers as optional extras and routinely puts secrets where they don't belong. Both of these defaults ship vulnerabilities.
Client-side secrets: the silent leak
When AI generates code that calls third-party APIs, it almost always puts the APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. key right next to the fetch call. If that code runs in the browser, the key is visible to anyone who opens DevTools.
// AI generates this for a React component
const OPENAI_KEY = 'sk-abc123...'; // visible in browser source
async function askAI(question) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${OPENAI_KEY}` },
// ...
});
}This is not obscured by building or bundling. The key ends up in the JavaScript bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together. that browsers download, and anyone can extract it.
| Where AI puts secrets | What actually happens | What you should do |
|---|---|---|
| In React/frontend code | Visible in browser DevTools, bundle | Move to server-side API route |
In .env without .gitignore | Committed to Git, visible in repo | Add .env to .gitignore before first commit |
| Hardcoded in server code | Exposed if source code leaks | Use environment variables |
In next.config.js public vars | Bundled into client-side code | Use server-only env vars (no NEXT_PUBLIC_ prefix) |
The fix is a proxy pattern: your frontend calls your own backend, and your backend calls the third-party API with the secret key:
// Frontend - no secrets here
const res = await fetch('/api/ask-ai', {
method: 'POST',
body: JSON.stringify({ question })
});
// Backend - secret stays server-side
app.post('/api/ask-ai', async (req, res) => {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${process.env.OPENAI_KEY}` },
// ...
});
const data = await response.json();
res.json(data);
});NEXT_PUBLIC_ or VITE_ prefixed environment variables for secret keys, thinking it's "using env vars properly." These prefixes specifically mean the variable is bundled into client-side code. For secrets, use non-prefixed env vars that are only available server-side, and access them through API routes or server-side rendering functions.Why security headers matter
Think of security headers as instructions you give to browsers. Without them, browsers make their own decisions about what to allow, and those defaults prioritize compatibility over security. With the right headers, you explicitly tell the browser: "Only load scripts from these domains. Never display this page inside an iframe. Always use HTTPSWhat is https?HTTP with encryption added, so data traveling between your browser and a server can't be read or tampered with by anyone in between.."
The practical benefit is defense in depth. Even if an attacker finds an XSSWhat is xss?Cross-Site Scripting - an attack where malicious JavaScript is injected into a web page and runs in other users' browsers, stealing data or hijacking sessions. vulnerability in your code, a strong Content Security PolicyWhat is content security policy?An HTTP header that tells the browser which sources of scripts, styles, and other resources are allowed to load on your page, blocking unauthorized code. might prevent their injected script from executing.
Content Security PolicyWhat is content security policy?An HTTP header that tells the browser which sources of scripts, styles, and other resources are allowed to load on your page, blocking unauthorized code.
CSP is the most powerful and complex security header. It defines a whitelist of sources from which the browser is allowed to load resources:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.comThis tells the browser: load everything from the same originWhat is origin?The combination of protocol, domain, and port that defines a security boundary in the browser, like https://example.com:443. by default, and also allow scripts from cdn.example.com. Any script from anywhere else, including inline <script> tags, is blocked.
The key directives you'll configure most often:
| Directive | Controls | Example value |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript sources | 'self' https://cdn.example.com |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Image sources | 'self' data: https: |
connect-src | fetch/XHR destinations | 'self' https://api.example.com |
frame-src | Iframe sources | 'none' |
object-src | Flash and plugins | 'none' (always disable) |
'unsafe-inline' in script-src allows inline <script> tags and event handlers. Avoid it if possible, it significantly weakens XSS protection. Modern approaches use nonces instead.Testing CSP without breaking your site
CSP misconfigurations break real functionality. Test with Content-Security-Policy-Report-Only first, it logs violations without actually blocking anything:
res.setHeader(
'Content-Security-Policy-Report-Only',
"default-src 'self'; script-src 'self'; report-uri /csp-report"
);Other essential headers
| Header | Protects against | Recommended value |
|---|---|---|
Strict-Transport-Security | SSL stripping, downgrade attacks | max-age=31536000; includeSubDomains |
X-Frame-Options | Clickjacking | DENY |
X-Content-Type-Options | MIME sniffing attacks | nosniff |
Referrer-Policy | Sensitive URL leakage | strict-origin-when-cross-origin |
Permissions-Policy | Feature abuse after XSS | Disable unused APIs with () |
HSTSWhat is hsts?HTTP Strict Transport Security - a response header that tells browsers to only connect to your site over HTTPS for a specified duration.
HSTS forces browsers to always use HTTPSWhat is https?HTTP with encryption added, so data traveling between your browser and a server can't be read or tampered with by anyone in between.. Once a browser receives this header, it converts all HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. requests to HTTPS internally, the insecure request never leaves the browser:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadX-Frame-Options
ClickjackingWhat is clickjacking?An attack where a malicious site embeds your page in a hidden iframe and tricks users into clicking elements on it unknowingly. is an attack where a malicious site embeds your application in a transparent iframe and overlays invisible buttons that trick users into clicking things in your app. X-Frame-Options: DENY stops it.
Implementing with helmetWhat is helmet?An Express middleware package that sets recommended HTTP security headers (CSP, HSTS, X-Frame-Options) in one line..js
Configuring all these headers manually is tedious and error-prone. helmet.js is the standard solution for Express:
const helmet = require('helmet');
// One line - sensible defaults for all headers
app.use(helmet());
// Customized for production
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));app.use(helmet()) as one of the first middleware lines in any Express app.Quick security checklist
| Check | How to verify |
|---|---|
| No secrets in frontend code | Search codebase for API key patterns (sk-, key=, hardcoded tokens) |
.env in .gitignore | Run git log --all -- .env, should return nothing |
| Security headers present | Run your domain through securityheaders.com |
| CSP configured | Check response headers in DevTools Network tab |
| HTTPS enforced | HSTS header present with long max-age |
// Security headers + secrets management
const express = require('express');
const helmet = require('helmet');
const app = express();
// Set all security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.myapp.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
// Proxy pattern: frontend never sees the secret
app.post('/api/ask-ai', async (req, res) => {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: req.body.question }]
})
});
const data = await response.json();
res.json(data);
});