Imported from reason-machines/security-skills (
skills/api-security-checklist/SKILL.md). Install upstream withnpx skills add reason-machines/security-skills --skill api-security-checklist. Copyright stays with the author.
API Security Checklist Skill
Skill by ara.so — Security Skills collection.
This skill provides comprehensive guidance on API security best practices based on the widely-adopted API Security Checklist. Use this to design, audit, and secure REST, GraphQL, and OAuth APIs against common vulnerabilities and attack vectors.
Overview
The API Security Checklist covers critical security countermeasures across:
- Authentication - Secure user identity verification
- Authorization - Access control and OAuth flows
- Input Validation - Preventing injection attacks
- Output Security - Secure response handling
- Processing - Backend security measures
- Monitoring - Detection and alerting
- CI/CD - Secure development lifecycle
Installation
This is a knowledge resource, not a software package. To use:
- Bookmark for reference: Keep the checklist accessible during API development
- Integrate into code reviews: Use as a PR checklist template
- Add to CI/CD: Implement automated checks based on these guidelines
- Security audits: Use as an audit framework
Authentication Security
❌ Avoid Basic Auth
// BAD - Basic Auth is insecure
app.get('/api/users', (req, res) => {
const auth = req.headers.authorization;
const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
// Don't do this!
});
✅ Use Standard Authentication
// GOOD - JWT with proper validation
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
app.get('/api/users', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
Rate Limiting and Max Retry
const rateLimit = require('express-rate-limit');
// Limit login attempts
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/login', loginLimiter, async (req, res) => {
// Login logic
});
Password Storage
const bcrypt = require('bcrypt');
// GOOD - Hash passwords with bcrypt
async function hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// Usage
app.post('/api/register', async (req, res) => {
const { email, password } = req.body;
const hashedPassword = await hashPassword(password);
// Store hashedPassword in database
});
Access Control
HTTPS and Security Headers
const helmet = require('helmet');
const express = require('express');
const app = express();
// Use Helmet for security headers
app.use(helmet({
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"]
}
},
frameguard: { action: 'deny' },
noSniff: true
}));
// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});
}
IP Whitelisting for Private APIs
const ipWhitelist = process.env.ALLOWED_IPS?.split(',') || [];
function checkIPWhitelist(req, res, next) {
const clientIP = req.ip || req.connection.remoteAddress;
if (!ipWhitelist.includes(clientIP)) {
return res.status(403).json({ error: 'IP not authorized' });
}
next();
}
app.use('/api/admin', checkIPWhitelist);
DDoS Protection
const rateLimit = require('express-rate-limit');
// General API rate limiting
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many requests from this IP'
});
app.use('/api/', apiLimiter);
OAuth Security
Validate Redirect URI
from urllib.parse import urlparse
ALLOWED_REDIRECT_URIS = [
'https://app.example.com/callback',
'https://app.example.com/oauth/callback'
]
def validate_redirect_uri(redirect_uri):
"""Always validate redirect_uri server-side"""
if redirect_uri not in ALLOWED_REDIRECT_URIS:
raise ValueError('Invalid redirect_uri')
return True
# In OAuth authorization endpoint
@app.route('/oauth/authorize')
def authorize():
redirect_uri = request.args.get('redirect_uri')
try:
validate_redirect_uri(redirect_uri)
except ValueError:
return {'error': 'invalid_redirect_uri'}, 400
# Continue with authorization
Use State Parameter for CSRF Protection
const crypto = require('crypto');
// Generate state parameter
function generateState() {
return crypto.randomBytes(32).toString('hex');
}
// OAuth authorization request
app.get('/oauth/login', (req, res) => {
const state = generateState();
// Store state in session
req.session.oauthState = state;
const authUrl = `https://provider.com/oauth/authorize?` +
`client_id=${process.env.OAUTH_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI)}` +
`&response_type=code` +
`&state=${state}` +
`&scope=read`;
res.redirect(authUrl);
});
// OAuth callback - validate state
app.get('/oauth/callback', (req, res) => {
const { code, state } = req.query;
// Validate state parameter
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state parameter' });
}
// Exchange code for token
// Never use response_type=token (implicit flow)
});
Scope Validation
const VALID_SCOPES = ['read', 'write', 'admin'];
const DEFAULT_SCOPE = 'read';
function validateScopes(requestedScopes) {
if (!requestedScopes) return [DEFAULT_SCOPE];
const scopes = requestedScopes.split(' ');
const validScopes = scopes.filter(scope => VALID_SCOPES.includes(scope));
return validScopes.length > 0 ? validScopes : [DEFAULT_SCOPE];
}
app.post('/oauth/token', (req, res) => {
const requestedScopes = req.body.scope;
const allowedScopes = validateScopes(requestedScopes);
// Generate token with validated scopes only
const token = jwt.sign(
{ scopes: allowedScopes },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ access_token: token, scope: allowedScopes.join(' ') });
});
Input Validation
HTTP Method Validation
const ALLOWED_METHODS = {
'/api/users': ['GET', 'POST'],
'/api/users/:id': ['GET', 'PUT', 'PATCH', 'DELETE']
};
function validateMethod(req, res, next) {
const allowedForRoute = ALLOWED_METHODS[req.route.path];
if (!allowedForRoute || !allowedForRoute.includes(req.method)) {
res.set('Allow', allowedForRoute.join(', '));
return res.status(405).json({ error: 'Method Not Allowed' });
}
next();
}
app.use(validateMethod);
Content-Type Validation
const SUPPORTED_CONTENT_TYPES = [
'application/json',
'application/xml'
];
function validateContentType(req, res, next) {
// Validate Accept header
const accept = req.headers.accept;
const acceptsSupported = SUPPORTED_CONTENT_TYPES.some(type =>
accept?.includes(type)
);
if (!acceptsSupported && accept !== '*/*') {
return res.status(406).json({ error: 'Not Acceptable' });
}
// Validate Content-Type for POST/PUT/PATCH
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type']?.split(';')[0];
if (!SUPPORTED_CONTENT_TYPES.includes(contentType)) {
return res.status(415).json({ error: 'Unsupported Media Type' });
}
}
next();
}
app.use(validateContentType);
Input Sanitization
const validator = require('validator');
// Prevent XSS, SQL Injection, etc.
function sanitizeInput(data) {
if (typeof data === 'string') {
return validator.escape(data);
}
if (Array.isArray(data)) {
return data.map(sanitizeInput);
}
if (typeof data === 'object' && data !== null) {
const sanitized = {};
for (const [key, value] of Object.entries(data)) {
sanitized[key] = sanitizeInput(value);
}
return sanitized;
}
return data;
}
app.post('/api/users', (req, res) => {
const sanitizedBody = sanitizeInput(req.body);
// Use sanitizedBody instead of req.body
});
Prevent XXE (XML External Entity)
const libxmljs = require('libxmljs');
function parseXMLSafely(xmlString) {
try {
// Disable external entity parsing
const doc = libxmljs.parseXml(xmlString, {
noent: false, // Don't substitute entities
nonet: true, // Don't access network
dtdload: false // Don't load external DTDs
});
return doc;
} catch (error) {
throw new Error('Invalid XML');
}
}
app.post('/api/data', (req, res) => {
if (req.headers['content-type'] === 'application/xml') {
try {
const doc = parseXMLSafely(req.body);
// Process document
} catch (error) {
return res.status(400).json({ error: 'Invalid XML' });
}
}
});
Processing Security
Avoid Auto-Increment IDs (Use UUIDs)
const { v4: uuidv4 } = require('uuid');
// GOOD - Use UUIDs instead of auto-increment IDs
app.post('/api/users', async (req, res) => {
const user = {
id: uuidv4(), // e.g., '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
...req.body
};
await db.users.create(user);
res.json(user);
});
Use /me for User Resources
// BAD - Exposes user IDs
app.get('/api/users/:userId/orders', (req, res) => {
// User could change userId to access others' data
});
// GOOD - Use /me for authenticated user resources
app.get('/api/me/orders', authenticateToken, async (req, res) => {
const orders = await db.orders.find({ userId: req.user.id });
res.json(orders);
});
Background Processing with Workers
const Queue = require('bull');
const uploadQueue = new Queue('file-uploads', process.env.REDIS_URL);
// Don't block HTTP response with heavy processing
app.post('/api/upload', async (req, res) => {
const { file } = req.body;
// Add to queue immediately
const job = await uploadQueue.add({
fileId: file.id,
userId: req.user.id
});
// Return fast response
res.status(202).json({
message: 'Upload processing',
jobId: job.id
});
});
// Process in background worker
uploadQueue.process(async (job) => {
const { fileId, userId } = job.data;
// Heavy processing here
await processLargeFile(fileId);
});
Disable Debug Mode in Production
// Check environment
if (process.env.NODE_ENV === 'production') {
// Disable verbose error messages
app.use((err, req, res, next) => {
console.error(err.stack); // Log server-side only
res.status(500).json({
error: 'Internal Server Error' // Generic message
});
});
} else {
// Development - show detailed errors
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack
});
});
}
Output Security
Security Headers
app.use((req, res, next) => {
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'deny');
// Content Security Policy
res.setHeader('Content-Security-Policy', "default-src 'none'");
// Remove fingerprinting headers
res.removeHeader('X-Powered-By');
next();
});
Force Content-Type
app.get('/api/users', (req, res) => {
const users = [{ id: 1, name: 'John' }];
// Always set explicit content-type
res.setHeader('Content-Type', 'application/json');
res.json(users);
});
Generic Error Messages
import logging
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(error):
# Log detailed error server-side
logger.error(f"Error occurred: {str(error)}", exc_info=True)
# Return generic message to client
return {
'error': 'An error occurred processing your request'
}, 500
# Don't return sensitive details
@app.route('/api/users/<user_id>')
def get_user(user_id):
try:
user = db.query(f"SELECT * FROM users WHERE id = ?", [user_id])
return jsonify(user)
except DatabaseError as e:
# BAD - exposes database structure
# return {'error': f'Database error: {str(e)}'}, 500
# GOOD - generic message
logger.error(f"Database error for user {user_id}: {str(e)}")
return {'error': 'Unable to retrieve user'}, 500
Proper Status Codes
app.post('/api/users', async (req, res) => {
try {
const user = await createUser(req.body);
res.status(201).json(user); // 201 Created
} catch (error) {
if (error.type === 'VALIDATION_ERROR') {
res.status(400).json({ error: error.message }); // 400 Bad Request
} else if (error.type === 'DUPLICATE') {
res.status(409).json({ error: 'User already exists' }); // 409 Conflict
} else {
res.status(500).json({ error: 'Internal Server Error' });
}
}
});
app.delete('/api/users/:id', authenticateToken, async (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' }); // 403 Forbidden
}
const deleted = await deleteUser(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'User not found' }); // 404 Not Found
}
res.status(204).send(); // 204 No Content
});
GraphQL-Specific Security
Disable Introspection in Production
const { ApolloServer } = require('apollo-server');
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
playground: process.env.NODE_ENV !== 'production'
});
Query Depth Limiting
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)] // Max depth of 5
});
Query Cost Analysis
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log('Query cost:', cost);
}
})
]
});
Monitoring and Logging
Centralized Logging
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Log all requests
app.use((req, res, next) => {
logger.info({
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.headers['user-agent']
});
next();
});
// Don't log sensitive data
app.post('/api/login', (req, res) => {
logger.info({
event: 'login_attempt',
email: req.body.email
// DON'T log password
});
});
Alert on Suspicious Activity
const alertThreshold = 10;
const suspiciousIPs = new Map();
app.use((req, res, next) => {
const ip = req.ip;
const count = suspiciousIPs.get(ip) || 0;
if (res.statusCode === 401 || res.statusCode === 403) {
suspiciousIPs.set(ip, count + 1);
if (count + 1 >= alertThreshold) {
// Send alert
sendAlert({
type: 'suspicious_activity',
ip: ip,
failedAttempts: count + 1
});
}
}
next();
});
function sendAlert(alert) {
// Send to Slack, email, SMS, etc.
console.log('ALERT:', alert);
}
CI/CD Security
Dependency Scanning
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run dependency audit
run: npm audit --audit-level=moderate
- name: Check for known vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
Static Code Analysis
- name: Run static analysis
run: |
npm install -g eslint eslint-plugin-security
eslint . --ext .js --plugin security
Secret Scanning
- name: Scan for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: main
Common Patterns and Best Practices
API Key Management
const crypto = require('crypto');
// Generate API key
function generateAPIKey() {
return crypto.randomBytes(32).toString('hex');
}
// Store hashed API key
async function createAPIKey(userId) {
const apiKey = generateAPIKey();
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
await db.apiKeys.create({
userId,
keyHash: hashedKey,
createdAt: new Date()
});
// Return plain key only once
return apiKey;
}
// Validate API key
async function validateAPIKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const hashedKey = crypto.createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await db.apiKeys.findOne({ keyHash: hashedKey });
if (!keyRecord) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = { id: keyRecord.userId };
next();
}
CORS Configuration
const cors = require('cors');
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
maxAge: 86400 // 24 hours
};
app.use(cors(corsOptions));
Request Signing
const crypto = require('crypto');
// Sign sensitive requests
function signRequest(payload, secret) {
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return signature;
}
// Verify request signature
function verifySignature(req, res, next) {
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-timestamp'];
// Prevent replay attacks (5 minute window)
if (Date.now() - parseInt(timestamp) > 300000) {
return res.status(401).json({ error: 'Request expired' });
}
const payload = { ...req.body, timestamp };
const expectedSignature = signRequest(payload, process.env.SIGNING_SECRET);
if (signature !== expectedSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
Troubleshooting
Issue: Rate limiting blocking legitimate users
Solution: Implement sliding window with user identification
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const limiter = rateLimit({
store: new RedisStore({
client: redisClient,
}),
windowMs: 15 * 60 * 1000,
max: async (req) => {
// Authenticated users get higher limits
if (req.user) return 1000;
return 100;
},
keyGenerator: (req) => {
// Use user ID for authenticated, IP for anonymous
return req.user ? req.user.id : req.ip;
}
});
Issue: CORS errors in production
Solution: Properly configure CORS with environment-specific origins
const allowedOrigins = {
development: ['http://localhost:3000'],
production: ['https://app.example.com', 'https://www.example.com']
};
const origins = allowedOrigins[process.env.NODE_ENV] || [];
Issue: Token expiration causing user logouts
Solution: Implement refresh token pattern
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived
);
const refreshToken = jwt.sign(
{ userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // Long-lived
);
return { accessToken, refreshToken };
}
app.post('/api/refresh', (req, res) => {
const { refreshToken } = req.body;
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.userId);
res.json({ accessToken, refreshToken: newRefreshToken });
});
});
Security Audit Checklist
Use this checklist when reviewing APIs:
Authentication
- No Basic Auth in use
- Passwords properly hashed (bcrypt, Argon2)
- Rate limiting on login endpoints
- MFA available for sensitive operations
Authorization
- OAuth redirect_uri validation
- CSRF protection with state parameter
- Proper scope validation
- Access control checked on every endpoint
Input
- HTTP method validation
- Content-Type validation
- Input sanitization
- XXE protection for XML
- No sensitive data in URLs
Output
- Security headers set
- Generic error messages
- Proper status codes
- No sensitive data in responses
Infrastructure
- HTTPS enforced
- HSTS enabled
- Rate limiting implemented
- Debug mode disabled in production
Monitoring
- Centralized logging
- No sensitive data logged
- Alerts configured
- Security events tracked
Resources
- Original checklist: https://github.com/shieldfy/API-Security-Checklist
- OWASP API Security Top 10: https://owasp.org/www-project-api-security/
- OAuth 2.0 Security Best Practices: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics