Secure API Design
AI-generated APIs work great in demos and fall apart in production. They return too much data, accept requests from anywhere, have no rate limits, and use JWTs with infinite expiry. The API surface is the front door to your application — and AI leaves it wide open.
skilldb get vibe-coding-security-skills/secure-api-designFull skill: 402 linesSecure API Design
AI-generated APIs work great in demos and fall apart in production. They return too much data, accept requests from anywhere, have no rate limits, and use JWTs with infinite expiry. The API surface is the front door to your application — and AI leaves it wide open.
This skill covers the security patterns every API needs before facing the internet.
Rate Limiting
AI never adds rate limiting. Every endpoint it generates can be hammered indefinitely.
Express Rate Limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Global rate limit
const globalLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
keyGenerator: (req) => req.ip, // or req.user?.id for authenticated
});
// Strict limit for auth endpoints (prevent brute force)
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 15 * 60 * 1000,
max: 5, // 5 login attempts per 15 minutes
skipSuccessfulRequests: true,
message: { error: 'Too many login attempts' },
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
Python / FastAPI Rate Limiting
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address, storage_uri="redis://localhost:6379")
@app.post("/api/auth/login")
@limiter.limit("5/15minutes")
async def login(request: Request, credentials: LoginInput):
# ...
@app.get("/api/data")
@limiter.limit("100/hour")
async def get_data(request: Request):
# ...
Authentication Middleware
What AI generates:
// Check auth in each route handler — inconsistent and forgettable
app.get('/api/users', (req, res) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
// ... verify token inline
});
What production needs:
import jwt from 'jsonwebtoken';
interface AuthRequest extends Request {
user?: { id: string; role: string; tenantId: string };
}
function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Prevent algorithm confusion
audience: 'myapp-api', // Verify intended audience
issuer: 'myapp-auth', // Verify token source
maxAge: '15m', // Reject old tokens even if not expired
});
req.user = payload as AuthRequest['user'];
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
function requireRole(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Apply globally, whitelist public routes
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health'];
app.use((req, res, next) => {
if (publicPaths.includes(req.path)) return next();
return requireAuth(req, res, next);
});
// Role-based access
app.delete('/api/users/:id', requireRole('admin'), deleteUser);
API Key Management
import crypto from 'crypto';
// Generate API keys with a prefix for easy identification
function generateApiKey(): { key: string; hash: string } {
const key = `sk_live_${crypto.randomBytes(32).toString('hex')}`;
const hash = crypto.createHash('sha256').update(key).digest('hex');
return { key, hash }; // Store hash in DB, return key once to user
}
// Validate API key middleware
async function validateApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const hash = crypto.createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await db.query(
'SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL',
[hash]
);
if (!keyRecord.rows.length) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Rate limit per API key
const key = keyRecord.rows[0];
req.apiKeyOwner = key.user_id;
req.apiKeyScopes = key.scopes;
// Track usage
await db.query(
'UPDATE api_keys SET last_used_at = NOW(), request_count = request_count + 1 WHERE id = $1',
[key.id]
);
next();
}
JWT Best Practices
AI-generated JWTs are always wrong in at least one critical way.
// Token generation with proper security settings
function generateTokens(user: { id: string; role: string; tenantId: string }) {
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role,
tenantId: user.tenantId,
},
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m', // Short-lived access token
audience: 'myapp-api',
issuer: 'myapp-auth',
jwtid: crypto.randomUUID(), // Unique ID for revocation
}
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET, // Different secret!
{
algorithm: 'HS256',
expiresIn: '7d',
audience: 'myapp-auth',
issuer: 'myapp-auth',
jwtid: crypto.randomUUID(),
}
);
return { accessToken, refreshToken };
}
// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256'],
audience: 'myapp-auth',
});
// Check if refresh token is revoked (stored in Redis or DB)
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) {
return res.status(401).json({ error: 'Token revoked' });
}
// Revoke old refresh token (rotation)
await redis.set(`revoked:${payload.jti}`, '1', 'EX', 7 * 24 * 60 * 60);
// Issue new token pair
const user = await getUser(payload.sub);
const tokens = generateTokens(user);
return res.json(tokens);
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
Webhook Signature Verification
AI-generated webhook handlers almost never verify signatures.
import crypto from 'crypto';
// Stripe webhook verification
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
handleStripeEvent(event);
res.status(200).json({ received: true });
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
res.status(400).json({ error: 'Invalid signature' });
}
});
// Generic HMAC webhook verification
function verifyWebhookSignature(
payload: string | Buffer,
signature: string,
secret: string,
algorithm = 'sha256'
): boolean {
const expected = crypto
.createHmac(algorithm, secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
CORS Configuration
import cors from 'cors';
// NEVER this:
// app.use(cors()); // Allows ALL origins
// Production CORS
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://myapp.com',
'https://app.myapp.com',
];
// Allow requests with no origin (server-to-server, mobile apps)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
callback(new Error('Not allowed by CORS'));
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true,
maxAge: 600, // Cache preflight for 10 minutes
};
app.use(cors(corsOptions));
Response Filtering
AI returns entire database objects. This leaks internal data.
// DANGEROUS: What AI generates
app.get('/api/users/:id', async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
res.json(user.rows[0]); // Returns password_hash, internal_notes, etc.
});
// SAFE: Explicit field selection
const UserPublicSchema = z.object({
id: z.string(),
name: z.string(),
avatar_url: z.string().nullable(),
created_at: z.string(),
});
app.get('/api/users/:id', async (req, res) => {
const user = await db.query(
'SELECT id, name, avatar_url, created_at FROM users WHERE id = $1',
[req.params.id]
);
if (!user.rows.length) {
return res.status(404).json({ error: 'User not found' });
}
// Double-filter: schema strips any extra fields
const safe = UserPublicSchema.parse(user.rows[0]);
res.json(safe);
});
Security Headers
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: 'same-site' },
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Remove server identification
app.disable('x-powered-by');
The API Security Checklist
| Layer | Check | Status |
|---|---|---|
| Authentication | Every endpoint requires auth or is explicitly public | Required |
| Authorization | Resource access is checked per-request, not just role | Required |
| Rate limiting | Global + per-endpoint limits in place | Required |
| Input validation | Every input is schema-validated | Required |
| Output filtering | Responses only contain intended fields | Required |
| CORS | Origins are explicitly whitelisted | Required |
| Headers | Security headers via helmet or equivalent | Required |
| Webhooks | All inbound webhooks verify signatures | Required |
| Logging | Requests are logged without sensitive data | Required |
| Error handling | Production errors are generic, no stack traces | Required |
Every API endpoint that faces the internet needs every check on this list. AI will give you zero of them by default.
Install this skill directly: skilldb add vibe-coding-security-skills