Skip to main content
Technology & EngineeringVibe Coding Security402 lines

Secure API Design

Quick Summary3 lines
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 lines
Paste into your CLAUDE.md or agent config

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.

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

LayerCheckStatus
AuthenticationEvery endpoint requires auth or is explicitly publicRequired
AuthorizationResource access is checked per-request, not just roleRequired
Rate limitingGlobal + per-endpoint limits in placeRequired
Input validationEvery input is schema-validatedRequired
Output filteringResponses only contain intended fieldsRequired
CORSOrigins are explicitly whitelistedRequired
HeadersSecurity headers via helmet or equivalentRequired
WebhooksAll inbound webhooks verify signaturesRequired
LoggingRequests are logged without sensitive dataRequired
Error handlingProduction errors are generic, no stack tracesRequired

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

Get CLI access →