Security

API Security: Protecting Your Endpoints

Mayur Dabhi
Mayur Dabhi
March 2, 2026
20 min read

In today's interconnected world, APIs are the backbone of modern applications. They power mobile apps, single-page applications, microservices, and third-party integrations. But with great power comes great responsibility—unsecured APIs are one of the most common attack vectors for malicious actors.

According to recent security reports, API-related breaches have increased by over 300% in the last two years. From data leaks to unauthorized access, the consequences of poor API security can be devastating for both businesses and users.

In this comprehensive guide, you'll learn the essential security practices every developer must implement to protect their API endpoints from common vulnerabilities and sophisticated attacks.

The Cost of Insecure APIs

A single API breach can result in:

  • Data exposure — Customer PII, financial records, credentials
  • Financial loss — Average breach cost: $4.45 million (IBM 2023)
  • Reputation damage — Lost customer trust, negative press
  • Legal consequences — GDPR fines up to €20 million or 4% of revenue

OWASP API Security Top 10

The Open Web Application Security Project (OWASP) maintains a list of the most critical API security risks. Understanding these is the first step to building secure APIs:

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 verifies who is making the request. Without proper authentication, your API is essentially a public resource available to anyone.

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

JSON Web Tokens (JWT) are the most common method for API authentication. Here's a secure implementation:

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 Security Best Practices
  • Short expiration — Access tokens should expire in 15-30 minutes
  • Secure storage — Store refresh tokens in HTTP-only cookies, not localStorage
  • Strong secrets — Use 256+ bit random secrets for signing
  • Token rotation — Issue new refresh tokens on each use
  • Revocation strategy — Implement token blocklist for logout

Authorization: Controlling Access

While authentication verifies identity, authorization determines what a user can do. This is where many APIs fail—proper authorization must be enforced at every endpoint.

Broken Object Level Authorization (BOLA)

The #1 API security risk. This occurs when an API doesn't verify whether a user has permission to access a specific resource:

Vulnerable Endpoint GET /api/users/123/orders — No ownership check!
Attacker Changes ID GET /api/users/456/orders — Accesses 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)

Implement granular permissions based on user roles:

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 limiting, your API is vulnerable to brute force attacks, DDoS, and resource exhaustion. Implement multiple layers of protection:

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

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

Never trust client input. Every piece of data from requests must be validated and sanitized before processing. This prevents injection attacks, data corruption, and security breaches.

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

All API communication must be encrypted. HTTPS is non-negotiable for any production API.

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

Use this checklist to audit your API security:

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
}));

Conclusion

API security is not a feature you add later—it must be built into your application from the start. The techniques covered in this guide form the foundation of a secure API:

Security is an ongoing process. Regularly audit your APIs, stay updated on new vulnerabilities, and consider professional penetration testing for critical applications.

"The only truly secure system is one that is powered off, cast in a block of concrete, and sealed in a lead-lined room with armed guards."
— Gene Spafford

While we can't achieve perfect security, we can make attacks expensive enough that most attackers move on to easier targets. Implement these practices, and you'll be well ahead of the curve. 🛡️

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.