API Security: Protecting Your Endpoints
Open your browser's network tab on almost any app, find a request like GET /api/orders/1042, and change the ID to 1041. On a surprising number of APIs, you get someone else's order back. The login works, the token is valid, and nobody ever checked whether that order belongs to you. Most API breaches look like this: boring, missing checks rather than clever exploits.
This post walks through the checks I want in place before an API goes live: authentication with JWTs, authorization (including the ownership check above), rate limiting, input validation, and HTTPS plus security headers. The examples are mostly Express, with Laravel and Flask versions for auth.
A single API breach can mean:
- Data exposure: customer PII, financial records, credentials
- Financial loss: IBM's 2023 Cost of a Data Breach report put the average at $4.45 million
- Reputation damage: lost customer trust and bad press
- Legal consequences: GDPR fines can reach €20 million or 4% of global annual revenue
OWASP API Security Top 10
OWASP publishes a separate Top 10 just for APIs (the 2023 edition is current). Here are the first six. If you only have time to audit for one, make it #1:
Broken Object Level Authorization
APIs expose endpoints that handle object IDs, creating attack surface for authorization flaws.
Broken Authentication
Weak authentication mechanisms allow attackers to compromise tokens or exploit implementation flaws.
Broken Object Property Level Authorization
Lack of validation for which object properties a user can access or modify.
Unrestricted Resource Consumption
APIs without rate limits enable denial of service and resource exhaustion attacks.
Broken Function Level Authorization
Complex access control policies with unclear separation between admin and regular functions.
Server-Side Request Forgery
APIs fetching remote resources without validating user-supplied URLs.
Authentication: The First Line of Defense
Authentication answers one question: who is making this request? Get it wrong and every other check downstream is working off a lie.
Token-based authentication flow: Client obtains token from auth server, then uses it for all API requests
JWT Authentication Implementation
JWTs are the most common choice for stateless API auth. Here's the same setup in three stacks: short-lived access tokens, a longer-lived refresh token, and a generic error on bad credentials.
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// Environment variables (NEVER hardcode secrets!)
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = '15m'; // Short-lived tokens
const REFRESH_EXPIRES_IN = '7d';
// Generate access token
function generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRES_IN,
issuer: 'your-api.com',
audience: 'your-app'
}
);
}
// Generate refresh token (store in database)
async function generateRefreshToken(userId) {
const token = crypto.randomBytes(64).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db.refreshTokens.create({
token: await bcrypt.hash(token, 10),
userId,
expiresAt
});
return token;
}
// Authentication middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
req.user = decoded;
next();
});
}
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
// Use same message to prevent user enumeration
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = await generateRefreshToken(user.id);
// Set refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken, expiresIn: 900 }); // 15 minutes
});
<?php
// Laravel JWT Authentication using Sanctum
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
// config/sanctum.php
return [
'expiration' => 60, // Minutes
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
];
// Login Controller
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required|min:8',
]);
$user = User::where('email', $request->email)->first();
// Prevent user enumeration - same message for both cases
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'error' => 'Invalid credentials'
], 401);
}
// Check for too many failed attempts (rate limiting)
if ($user->failed_login_attempts >= 5) {
return response()->json([
'error' => 'Account locked. Please try again later.'
], 429);
}
// Create token with abilities (permissions)
$token = $user->createToken('api-token', [
'read:profile',
'write:profile',
$user->is_admin ? 'admin:*' : null
])->plainTextToken;
// Reset failed attempts
$user->update(['failed_login_attempts' => 0]);
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
'expires_in' => config('sanctum.expiration') * 60
]);
}
public function logout(Request $request)
{
// Revoke current token
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
// Middleware: api routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
// Check token abilities
Route::middleware('ability:admin:*')->group(function () {
Route::get('/admin/users', [AdminController::class, 'users']);
});
});
from flask import Flask, request, jsonify
from flask_jwt_extended import (
JWTManager, create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, get_jwt
)
from werkzeug.security import check_password_hash
from datetime import timedelta
import os
app = Flask(__name__)
# Configuration
app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY')
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(minutes=15)
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=7)
app.config['JWT_TOKEN_LOCATION'] = ['headers']
app.config['JWT_HEADER_NAME'] = 'Authorization'
app.config['JWT_HEADER_TYPE'] = 'Bearer'
jwt = JWTManager(app)
# Token blocklist for logout
BLOCKLIST = set()
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
jti = jwt_payload['jti']
return jti in BLOCKLIST
@app.route('/api/auth/login', methods=['POST'])
def login():
email = request.json.get('email')
password = request.json.get('password')
user = User.query.filter_by(email=email).first()
# Prevent timing attacks - always hash even if user not found
dummy_hash = '$2b$12$dummy.hash.for.timing.attack.prevention'
password_to_check = user.password_hash if user else dummy_hash
if not user or not check_password_hash(password_to_check, password):
return jsonify({'error': 'Invalid credentials'}), 401
# Include user claims
additional_claims = {
'role': user.role,
'permissions': user.permissions
}
access_token = create_access_token(
identity=user.id,
additional_claims=additional_claims
)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({
'access_token': access_token,
'refresh_token': refresh_token,
'expires_in': 900
})
@app.route('/api/auth/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
identity = get_jwt_identity()
user = User.query.get(identity)
access_token = create_access_token(
identity=identity,
additional_claims={'role': user.role}
)
return jsonify({'access_token': access_token})
@app.route('/api/auth/logout', methods=['POST'])
@jwt_required()
def logout():
jti = get_jwt()['jti']
BLOCKLIST.add(jti)
return jsonify({'message': 'Token revoked'})
- Short expiration: access tokens should expire in 15 to 30 minutes
- Secure storage: keep refresh tokens in HTTP-only cookies, not localStorage
- Strong secrets: sign with a random secret of at least 256 bits
- Token rotation: issue a new refresh token every time one is used
- Revocation: keep a blocklist (in Redis, not in memory) so logout actually works
- Pin the algorithm: pass
algorithms: ['HS256']tojwt.verify()so a token can't pick its own. Also note thatjwt.decode()does not verify the signature, so it should never appear in auth middleware
Authorization: Controlling Access
Knowing who the user is doesn't tell you what they're allowed to touch. This is where most APIs slip, because the check has to happen on every endpoint and it only takes one forgotten route.
Broken Object Level Authorization (BOLA)
This is #1 on the OWASP list and it's the bug from the intro. The API trusts the ID in the URL and never asks whether the record belongs to the caller:
// ❌ VULNERABLE - No authorization check
app.get('/api/orders/:orderId', authenticateToken, async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
res.json(order); // Anyone can view any order!
});
// ✅ SECURE - Proper authorization
app.get('/api/orders/:orderId', authenticateToken, async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// Check ownership or admin role
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
res.json(order);
});
// Even better: Use middleware for reusability
const authorizeResource = (resourceType) => async (req, res, next) => {
const resource = await db[resourceType].findById(req.params.id);
if (!resource) {
return res.status(404).json({ error: 'Not found' });
}
const isOwner = resource.userId === req.user.id;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
req.resource = resource;
next();
};
app.get('/api/orders/:id',
authenticateToken,
authorizeResource('orders'),
(req, res) => res.json(req.resource)
);
Role-Based Access Control (RBAC)
For function-level checks (can this user delete users at all?), map roles to permissions and check permissions in the route, not role names:
// Define permissions per role
const PERMISSIONS = {
admin: ['read:users', 'write:users', 'delete:users', 'read:orders', 'write:orders', 'read:analytics'],
manager: ['read:users', 'read:orders', 'write:orders', 'read:analytics'],
user: ['read:profile', 'write:profile', 'read:orders', 'write:orders'],
guest: ['read:products']
};
// Permission checking middleware
const requirePermission = (...requiredPermissions) => {
return (req, res, next) => {
const userRole = req.user.role;
const userPermissions = PERMISSIONS[userRole] || [];
const hasPermission = requiredPermissions.every(
perm => userPermissions.includes(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions
});
}
next();
};
};
// Usage in routes
app.get('/api/users',
authenticateToken,
requirePermission('read:users'),
usersController.list
);
app.delete('/api/users/:id',
authenticateToken,
requirePermission('delete:users'),
usersController.delete
);
app.get('/api/analytics/dashboard',
authenticateToken,
requirePermission('read:analytics'),
analyticsController.dashboard
);
Rate Limiting: Preventing Abuse
Without rate limits, your login endpoint is a free password-guessing service and any expensive query can be hammered until the database falls over. I use three layers, each tighter than the last:
Three layers of rate limiting: Global → Per-User → Per-Endpoint
One production gotcha: behind Nginx or a load balancer, req.ip is the proxy's address unless you set app.set('trust proxy', 1). Forget it and every user shares one bucket, so the whole site gets throttled at once. Recent versions of express-rate-limit warn about this with an ERR_ERL_UNEXPECTED_X_FORWARDED_FOR validation error when it sees an X-Forwarded-For header without trust proxy configured.
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
// Global rate limiter
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 1000, // 1000 requests per minute globally
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
message: { error: 'Too many requests, please try again later' }
});
// Per-user rate limiter
const userLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100, // 100 requests per user per minute
keyGenerator: (req) => req.user?.id || req.ip,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:user:'
}),
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
});
}
});
// Strict limiter for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per 15 minutes
keyGenerator: (req) => req.ip + ':' + req.body.email,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:auth:'
}),
skipSuccessfulRequests: true, // Don't count successful logins
message: {
error: 'Too many login attempts',
retryAfter: 900 // 15 minutes in seconds
}
});
// Apply limiters
app.use(globalLimiter);
app.use('/api', userLimiter);
app.post('/api/auth/login', authLimiter, authController.login);
app.post('/api/auth/register', authLimiter, authController.register);
app.post('/api/auth/forgot-password', authLimiter, authController.forgotPassword);
Input Validation: Trust No One
Anything in the request body, query string, or headers is attacker-controlled. Validate its shape and type at the edge, drop fields you didn't ask for, and only then pass it to your business logic.
SQL Injection Critical Risk
Attackers inject malicious SQL through unsanitized input to access, modify, or delete database records.
const Joi = require('joi');
// Define validation schemas
const schemas = {
createUser: Joi.object({
email: Joi.string()
.email()
.required()
.lowercase()
.trim()
.max(255),
password: Joi.string()
.required()
.min(8)
.max(128)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.messages({
'string.pattern.base': 'Password must include uppercase, lowercase, number, and special character'
}),
name: Joi.string()
.required()
.trim()
.min(2)
.max(100)
.pattern(/^[a-zA-Z\s'-]+$/)
.messages({
'string.pattern.base': 'Name can only contain letters, spaces, hyphens, and apostrophes'
}),
age: Joi.number()
.integer()
.min(13)
.max(120)
.optional(),
role: Joi.string()
.valid('user', 'editor') // Never allow 'admin' from client!
.default('user')
}),
updateProfile: Joi.object({
name: Joi.string().trim().min(2).max(100),
bio: Joi.string().max(500).allow(''),
avatar: Joi.string().uri({ scheme: ['https'] })
}).min(1), // At least one field required
queryParams: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string().valid('createdAt', 'name', 'email').default('createdAt'),
order: Joi.string().valid('asc', 'desc').default('desc'),
search: Joi.string().max(100).trim()
})
};
// Validation middleware
const validate = (schemaName, source = 'body') => {
return (req, res, next) => {
const schema = schemas[schemaName];
const data = req[source];
const { error, value } = schema.validate(data, {
abortEarly: false, // Return all errors
stripUnknown: true // Remove unknown fields
});
if (error) {
const errors = error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}));
return res.status(400).json({
error: 'Validation failed',
details: errors
});
}
req[source] = value; // Use sanitized data
next();
};
};
// Usage
app.post('/api/users',
validate('createUser'),
userController.create
);
app.patch('/api/profile',
authenticateToken,
validate('updateProfile'),
userController.updateProfile
);
app.get('/api/users',
authenticateToken,
validate('queryParams', 'query'),
userController.list
);
Preventing SQL Injection
// ❌ VULNERABLE - String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Attacker input: ' OR '1'='1' --
// Result: SELECT * FROM users WHERE email = '' OR '1'='1' --'
// ✅ SECURE - Parameterized queries
// With mysql2
const [rows] = await connection.execute(
'SELECT * FROM users WHERE email = ? AND status = ?',
[email, 'active']
);
// With Sequelize ORM
const user = await User.findOne({
where: {
email: email,
status: 'active'
}
});
// With Prisma
const user = await prisma.user.findUnique({
where: { email: email }
});
// With Knex.js
const user = await knex('users')
.where({ email, status: 'active' })
.first();
// MongoDB with Mongoose - still validate input!
const user = await User.findOne({
email: { $eq: email } // Use $eq to prevent operator injection
});
// ❌ VULNERABLE MongoDB
const user = await User.findOne({ email: req.body.email });
// Attacker sends: { "email": { "$ne": "" } }
// Returns first user with non-empty email!
// ✅ SECURE MongoDB - validate type
if (typeof req.body.email !== 'string') {
return res.status(400).json({ error: 'Invalid email' });
}
const user = await User.findOne({ email: req.body.email });
HTTPS & Transport Security
Bearer tokens sent over plain HTTP can be read by anyone on the network path, so production APIs need HTTPS everywhere. Helmet covers most of the headers, and the redirect below handles apps that sit behind a TLS-terminating load balancer.
const helmet = require('helmet');
// Apply security headers
app.use(helmet({
// Strict Transport Security - force HTTPS
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
// Prevent clickjacking
frameguard: { action: 'deny' },
// XSS protection
xssFilter: true,
// Prevent MIME sniffing
noSniff: true,
// Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourdomain.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
}
}));
// Force HTTPS redirect (for apps behind load balancer)
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' &&
process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
});
// CORS configuration
const cors = require('cors');
app.use(cors({
origin: ['https://yourapp.com', 'https://admin.yourapp.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // 24 hours
}));
API Security Checklist
What I go through when auditing an existing API:
-
Authentication JWT with short expiration, secure token storage, refresh token rotation
-
Authorization RBAC implemented, BOLA prevention, resource ownership verification
-
Rate Limiting Global, per-user, and per-endpoint limits with Redis-backed storage
-
Input Validation Schema validation, type checking, sanitization of all user input
-
SQL Injection Prevention Parameterized queries, ORM usage, no string concatenation
-
HTTPS Enforcement TLS 1.2+, HSTS headers, secure cookies, certificate monitoring
-
Security Headers CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
-
Error Handling Generic error messages, no stack traces in production, logging
-
Logging & Monitoring Request logging, anomaly detection, security alerts
-
Secrets Management Environment variables, secrets vault, no hardcoded credentials
Common Mistakes to Avoid
Exposing Sensitive Data in Responses
// ❌ BAD - Returns everything from database
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // Includes password hash, tokens, internal IDs!
});
// ✅ GOOD - Explicit field selection
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id)
.select('id name email avatar createdAt');
res.json(user);
});
// Even better: Use DTOs/transformers
const userDTO = (user) => ({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
memberSince: user.createdAt
});
Verbose Error Messages
// ❌ BAD - Reveals system information
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack, // Exposes file paths!
query: err.sql, // Exposes database queries!
config: process.env // Exposes secrets!
});
});
// ✅ GOOD - Generic errors, detailed logging
app.use((err, req, res, next) => {
// Log full error internally
logger.error({
error: err.message,
stack: err.stack,
requestId: req.id,
userId: req.user?.id,
path: req.path,
method: req.method
});
// Return generic message to client
res.status(err.status || 500).json({
error: err.status < 500
? err.message
: 'An unexpected error occurred',
requestId: req.id // For support reference
});
});
Missing CORS Configuration
// ❌ BAD - Allows any origin
app.use(cors()); // Defaults to origin: '*'
// ❌ BAD - Dynamic origin without validation
app.use(cors({
origin: req.headers.origin // Reflects attacker's origin!
}));
// ✅ GOOD - Explicit allowlist
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));
Where I'd Spend Effort First (and What I'd Skip)
Not every API needs every layer on day one. Here's how I'd prioritize:
- Do first, always: ownership checks on every route that takes an ID. Write a test that logs in as user A and requests user B's resource; expect a 403 or 404. It's cheap and it catches the most common real bug.
- JWTs aren't always the answer: if your API only serves your own same-domain SPA, Laravel Sanctum's cookie-based session auth or a plain server session is simpler and easier to revoke. JWTs earn their keep with mobile clients and multiple services that need to verify tokens independently.
- Skip the in-memory stores in production: the Flask
BLOCKLIST = set()and express-rate-limit's default memory store reset on every deploy and don't share state across instances. Fine for a demo, wrong for anything running on more than one process. - Don't over-engineer rate limits early: a strict limit on login, register, and password reset covers most abuse. Per-user and global tiers matter once you have real traffic or paying API customers.
- Strict password regexes are optional: current NIST guidance favors length and checking against breached-password lists over composition rules. I'd keep the minimum length and drop the "must include a symbol" rule if users complain.
None of this replaces a proper penetration test for an API that handles payments or health data. But the checks above will stop the attacks that actually show up in your logs, which are mostly scripts trying IDs and passwords in a loop.