Security

API Security: Protecting Your Endpoints

Mayur Dabhi
Mayur Dabhi
March 2, 2026
20 min read

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.

What an Insecure API Actually Costs

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:

1

Broken Object Level Authorization

APIs expose endpoints that handle object IDs, creating attack surface for authorization flaws.

2

Broken Authentication

Weak authentication mechanisms allow attackers to compromise tokens or exploit implementation flaws.

3

Broken Object Property Level Authorization

Lack of validation for which object properties a user can access or modify.

4

Unrestricted Resource Consumption

APIs without rate limits enable denial of service and resource exhaustion attacks.

5

Broken Function Level Authorization

Complex access control policies with unclear separation between admin and regular functions.

6

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.

API Authentication Flow Client App/Browser 1. Credentials 🔐 Auth Server 2. JWT Token 3. Request + Bearer Token { } API API Server Validates Token 4. Protected Resource Database

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'})
JWT Rules I Stick To
  • 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'] to jwt.verify() so a token can't pick its own. Also note that jwt.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 Endpoint GET /api/users/123/orders (no ownership check)
Attacker Changes ID GET /api/users/456/orders returns another user's orders
Secure Implementation Verify req.user.id === resource.userId before returning data
Secure Authorization Example
// ❌ 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:

RBAC Implementation
// 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:

Multi-Layer Rate Limiting 📨 Requests 1000/sec Global Limit 10,000 req/min DDoS Protection IP Blocking ❌ 200 blocked Per-User Limit 100 req/min Token Bucket Sliding Window ❌ 50 throttled Endpoint Limit 10 req/min /login, /register Sensitive routes ✓ 750 allowed API 🛡️

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.

Rate Limiting with Express
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.

Input Validation with Joi
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

Safe Database Queries
// ❌ 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.

Security Headers Middleware
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:

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:

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.

API Security Authentication Authorization JWT OWASP Best Practices
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building secure, scalable web applications. Passionate about clean code and application security.