security
March 27, 2026 · 11 min read · 0 views

API Security Essentials: Authentication, Rate Limiting, CORS, and Vulnerability Prevention

A comprehensive guide to securing your APIs: implementing OAuth 2.0, JWT authentication, rate limiting, CORS configuration, and defending against common vulnerabilities like injection attacks and broken authentication.

API Security Essentials: Authentication, Rate Limiting, CORS, and Vulnerability Prevention

Building a secure API isn’t optional—it’s fundamental. Whether you’re exposing a public REST endpoint or an internal GraphQL service, attackers actively probe APIs for misconfigurations, weak authentication, and unpatched vulnerabilities. This guide walks through the practical security controls that protect your API and its consumers.

Why API Security Matters

APIs are high-value targets. They often handle sensitive data (authentication tokens, payment info, user records) and may sit behind fewer defenses than traditional web applications. A single compromised endpoint can expose thousands of records or enable unauthorized transactions.

Common attack vectors include:

  • Broken authentication: Weak tokens, poor session management, or missing verification
  • Injection attacks: SQL injection, command injection, or NoSQL injection through API parameters
  • Broken access control: Users accessing resources they shouldn’t, or privilege escalation
  • Rate limiting abuse: Brute-force attacks, credential stuffing, or DoS attacks
  • CORS misconfiguration: Allowing malicious cross-origin requests
  • Sensitive data exposure: Logging tokens, returning unencrypted data, or verbose error messages

The OWASP Top 10 for APIs (2023) emphasizes these exact risks. Let’s implement defenses against each.

Authentication: The First Line of Defense

Authentication verifies that a client is who they claim to be. Without it, anyone can call your API.

JWT (JSON Web Tokens)

JWTs are stateless, compact tokens ideal for APIs. A JWT consists of three parts: a header, payload, and signature.

{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "sub": "user123",
  "iat": 1700000000,
  "exp": 1700003600,
  "role": "admin",
  "email": "[email protected]"
}

Decoded, a JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsInJvbGUiOiJhZG1pbiIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSJ9.signature_here

You can decode and inspect any JWT at JWT Decoder to verify the payload structure.

Node.js example with Express and jsonwebtoken:

const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();

const SECRET_KEY = process.env.JWT_SECRET;

// Issue a token
app.post('/login', (req, res) => {
  const payload = {
    sub: req.body.userId,
    email: req.body.email,
    role: req.body.role,
    iat: Math.floor(Date.now() / 1000)
  };

  const token = jwt.sign(payload, SECRET_KEY, { expiresIn: '1h' });
  res.json({ token });
});

// Middleware to verify tokens
const authenticate = (req, res, next) => {
  const auth = req.headers.authorization;
  if (!auth) return res.status(401).json({ error: 'Missing token' });

  const token = auth.split(' ')[1]; // Bearer <token>
  try {
    req.user = jwt.verify(token, SECRET_KEY);
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

// Protected endpoint
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.listen(3000);

Critical JWT best practices:

  1. Use RS256 or ES256 (asymmetric signing) for multi-service architectures. HS256 requires a shared secret, which scales poorly.
  2. Always include an expiration (exp) and issued-at (iat) claim. Refresh tokens separately.
  3. Never store sensitive data (passwords, credit cards) in the JWT—it’s readable, just signed.
  4. Sign with a strong key: At least 256 bits for HMAC, 2048+ bits for RSA.
  5. Validate the signature on every request. Never skip verification.

OAuth 2.0

For third-party integrations or delegated access, OAuth 2.0 is the industry standard.

Authorization Code flow (most common for user-facing apps):

1. User clicks "Login with GitHub"
2. App redirects: https://github.com/login/oauth/authorize?client_id=YOUR_ID&redirect_uri=YOUR_CALLBACK
3. User logs in at GitHub, grants permission
4. GitHub redirects: https://yourapp.com/callback?code=AUTH_CODE
5. Your backend exchanges code for access token (server-to-server, more secure)
6. You receive access_token, store it securely, and create a session

Python example with Flask and authlib:

from flask import Flask, redirect, session
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = 'your-secret-key'
oauth = OAuth(app)

github = oauth.register(
    name='github',
    client_id='YOUR_GITHUB_CLIENT_ID',
    client_secret='YOUR_GITHUB_CLIENT_SECRET',
    authorize_url='https://github.com/login/oauth/authorize',
    access_token_url='https://github.com/login/oauth/access_token',
    api_base_url='https://api.github.com',
)

@app.route('/login')
def login():
    redirect_uri = url_for('authorize', _external=True)
    return github.authorize_redirect(redirect_uri)

@app.route('/authorize')
def authorize():
    token = github.authorize_access_token()
    user = github.get('user').json()
    session['user_id'] = user['id']
    return redirect('/')

@app.route('/api/profile')
def profile():
    if 'user_id' not in session:
        return {'error': 'Unauthorized'}, 401
    return {'user_id': session['user_id']}

OAuth 2.0 security notes:

  • Always validate the state parameter to prevent CSRF attacks.
  • Store tokens securely (encrypted database, not cookies).
  • Use refresh tokens to rotate long-lived credentials.
  • Implement token revocation when users log out.

Rate Limiting: Preventing Abuse

Rate limiting controls how frequently clients can call your API, mitigating brute-force attacks, DoS, and resource exhaustion.

Token Bucket Algorithm

The token bucket is the most flexible approach:

  • A “bucket” holds tokens (e.g., 100 tokens per minute).
  • Each request costs 1 token.
  • Tokens refill at a fixed rate (e.g., 1 per 0.6 seconds).
  • If the bucket is empty, the request is rejected.

Node.js example with express-rate-limit:

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');

const redisClient = redis.createClient();

const limiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:', // rate limit key prefix
  }),
  windowMs: 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute per IP
  message: 'Too many requests, please try again later.',
  standardHeaders: true, // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
});

// Apply to all API routes
app.use('/api/', limiter);

// Or apply to specific endpoints with stricter limits
const loginLimiter = rateLimit({
  store: new RedisStore({ client: redisClient, prefix: 'login:' }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 login attempts per 15 minutes
  skipSuccessfulRequests: true, // Don't count successful logins
});

app.post('/login', loginLimiter, (req, res) => {
  // Login logic
});

Python example with Flask-Limiter:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_limiter.storage import RedisStorage
import redis

redis_client = redis.Redis()
storage = RedisStorage(redis_client)
limiter = Limiter(
    app,
    key_func=get_remote_address,
    storage_uri='redis://localhost:6379',
    default_limits=['100 per minute']
)

@app.route('/api/data')
@limiter.limit('100 per minute')
def get_data():
    return {'data': 'value'}

@app.route('/login', methods=['POST'])
@limiter.limit('5 per 15 minutes')
def login():
    # Login logic
    pass

Rate limiting best practices:

  1. Use distributed storage (Redis, Memcached) if you have multiple API servers.
  2. Return clear rate limit headers:
    RateLimit-Limit: 100
    RateLimit-Remaining: 42
    RateLimit-Reset: 1700000060
  3. Consider user tier: Premium users might get 10,000 requests/hour while free users get 100.
  4. Rate limit by user ID, not just IP (authenticated endpoints).
  5. Implement exponential backoff on the client side when rate limited.

CORS: Controlling Cross-Origin Access

CORS (Cross-Origin Resource Sharing) prevents malicious websites from making unauthorized requests to your API on behalf of users.

How CORS Works

When a browser makes a cross-origin request, it first sends a preflight OPTIONS request:

OPTIONS /api/data HTTP/1.1
Origin: https://attacker.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

Your server responds with allowed origins, methods, and headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600

If the origin isn’t allowed, the browser blocks the response.

Express middleware for CORS:

const cors = require('cors');

// Permissive (development only—dangerous in production!)
app.use(cors());

// Restrictive (production)
const corsOptions = {
  origin: ['https://yourapp.com', 'https://app.yourcompany.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true, // Allow cookies/auth headers
  maxAge: 3600 // Cache preflight for 1 hour
};
app.use(cors(corsOptions));

// Or configure per route
app.get('/api/public', cors(), (req, res) => {
  res.json({ data: 'public' });
});

app.post('/api/admin', cors({ origin: 'https://admin.yourapp.com' }), (req, res) => {
  res.json({ data: 'admin-only' });
});

Python with Flask-CORS:

from flask_cors import CORS, cross_origin

# Global CORS with options
CORS(app, resources={
    r"/api/*": {
        "origins": ["https://yourapp.com"],
        "methods": ["GET", "POST", "PUT", "DELETE"],
        "allow_headers": ["Content-Type", "Authorization"],
        "supports_credentials": True,
        "max_age": 3600
    }
})

# Per-route decorator
@app.route('/api/data')
@cross_origin(origins=["https://yourapp.com"], supports_credentials=True)
def get_data():
    return {'data': 'value'}

CORS security checklist:

  1. Never use Access-Control-Allow-Origin: * with credentials. It’s a contradiction.
  2. Whitelist specific origins, not patterns like https://*.yourapp.com (attackers can register subdomains).
  3. Don’t trust the Origin header alone for sensitive operations—verify authentication/authorization.
  4. Cache preflight requests with Access-Control-Max-Age to reduce overhead.
  5. Be explicit about allowed methods and headers—don’t use wildcards.

Common Vulnerabilities and Defenses

Injection Attacks

SQL Injection occurs when user input is directly concatenated into SQL queries.

Vulnerable code:

const userId = req.query.id;
db.query(`SELECT * FROM users WHERE id = ${userId}`); // DANGEROUS!

An attacker could pass id=1 OR 1=1; DROP TABLE users;-- and destroy your database.

Secure code (parameterized queries):

const userId = req.query.id;
db.query('SELECT * FROM users WHERE id = ?', [userId]); // Safe

Parameterized queries treat the input as data, not code.

Validation and sanitization:

const { validationResult, query } = require('express-validator');

app.get('/api/user/:id',
  query('id').isInt().toInt(),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // id is now a safe integer
    db.query('SELECT * FROM users WHERE id = ?', [req.query.id]);
  }
);

Broken Authentication

Weak session tokens: Using sequential or predictable tokens makes credential stuffing trivial.

Secure token generation:

const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex'); // 256-bit random token

Or use a library like UUID Generator to create cryptographically secure identifiers.

No password storage validation:

// WRONG: storing plaintext or weak hash
db.query('INSERT INTO users (email, password) VALUES (?, ?)',
  [email, password] // Never!
);

// Correct: use bcrypt with salt
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 10); // 10 rounds
db.query('INSERT INTO users (email, password_hash) VALUES (?, ?)',
  [email, hash]
);

// Verify on login
const match = await bcrypt.compare(inputPassword, storedHash);
if (!match) return res.status(401).json({ error: 'Invalid credentials' });

Sensitive Data Exposure

Verbose error messages leak information:

// BAD: Reveals database internals
app.get('/api/user/:id', (req, res) => {
  db.query('SELECT * FROM users WHERE id = ?', [req.params.id], (err, rows) => {
    if (err) return res.json(err); // Error object exposed
    res.json(rows);
  });
});

// Good: Generic error response
app.get('/api/user/:id', (req, res) => {
  db.query('SELECT * FROM users WHERE id = ?', [req.params.id], (err, rows) => {
    if (err) {
      console.error(err); // Log internally
      return res.status(500).json({ error: 'Internal server error' });
    }
    if (!rows.length) return res.status(404).json({ error: 'Not found' });
    res.json(rows[0]);
  });
});

Logging tokens: Never log authentication headers or tokens.

// WRONG
console.log('Request headers:', req.headers); // May contain Authorization: Bearer <token>

// Better
const sanitized = { ...req.headers };
delete sanitized.authorization;
console.log('Request headers:', sanitized);

Broken Access Control

Checking authorization is just as critical as authentication.

// BAD: Only checks if user exists, not if they own the resource
app.get('/api/invoices/:id', authenticate, (req, res) => {
  db.query('SELECT * FROM invoices WHERE id = ?', [req.params.id], (err, rows) => {
    res.json(rows[0]);
  });
});

// Good: Verifies ownership
app.get('/api/invoices/:id', authenticate, (req, res) => {
  db.query(
    'SELECT * FROM invoices WHERE id = ? AND user_id = ?',
    [req.params.id, req.user.id],
    (err, rows) => {
      if (!rows.length) return res.status(403).json({ error: 'Forbidden' });
      res.json(rows[0]);
    }
  );
});

Security Headers

Set HTTP security headers to strengthen your API:

const helmet = require('helmet');

app.use(helmet()); // Sets sensible defaults

// Or configure manually
app.use((req, res, next) => {
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME sniffing
  res.setHeader('X-Frame-Options', 'DENY'); // Prevent clickjacking
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Content-Security-Policy', "default-src 'self'");
  next();
});

Logging and Monitoring

Security issues are easier to catch if you monitor for them:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'security.log', level: 'warn' }),
  ],
});

// Log failed authentication
app.post('/login', (req, res) => {
  const match = await bcrypt.compare(req.body.password, storedHash);
  if (!match) {
    logger.warn('Failed login attempt', {
      email: req.body.email,
      ip: req.ip,
      timestamp: new Date(),
    });
    return res.status(401).json({ error: 'Invalid credentials' });
  }
});

// Log suspicious rate limit violations
app.use((req, res, next) => {
  if (res.getHeader('RateLimit-Remaining') === '0') {
    logger.warn('Rate limit exceeded', {
      ip: req.ip,
      endpoint: req.path,
      timestamp: new Date(),
    });
  }
  next();
});

Testing Your API Security

Use the API Request Builder to test your endpoints and verify security headers are present.

  1. Send an unauthenticated request to a protected endpoint—it should return 401.
  2. Test with an expired JWT (adjust the exp claim in JWT Decoder to the past)—it should be rejected.
  3. Verify rate limiting: Send 101 requests in 60 seconds to a rate-limited endpoint—the 101st should fail.
  4. Check CORS: Inspect response headers for Access-Control-Allow-Origin—it should list your domain, not *.
  5. Test SQL injection: Pass id=1 OR 1=1 to a query parameter—it should return a single safe result or error gracefully.

Getting Started: Security Checklist

  1. Use strong authentication: Implement JWT or OAuth 2.0, not custom token schemes.
  2. Hash passwords: Bcrypt, scrypt, or Argon2—never plaintext.
  3. Enable HTTPS: All API traffic must be encrypted. Use TLS 1.2+.
  4. Rate limit: Apply per-endpoint limits, especially on authentication endpoints.
  5. Validate input: Use a validation library, reject invalid types/formats early.
  6. Parameterize queries: Never concatenate user input into SQL.
  7. Set CORS correctly: Whitelist specific origins, never use * with credentials.
  8. Log securely: Don’t log tokens or sensitive data.
  9. Return generic errors: Don’t expose database errors to clients.
  10. Use security headers: Helmet or manual headers to prevent clickjacking, MIME sniffing, etc.
  11. Rotate secrets: Change API keys, tokens, and database passwords regularly.
  12. Monitor for anomalies: Alert on repeated failed logins, unusual rate limit spikes, or unexpected error patterns.

Common Pitfalls

  • Trusting the client: Never assume client-side validation is sufficient—always validate server-side.
  • Reusing tokens: Don’t use the same token for authentication and authorization across services.
  • Ignoring expiration: Tokens without expiry are security nightmares. Always set exp.
  • Storing secrets in code: Use environment variables or a secrets manager.
  • Testing only happy paths: Security bugs hide in error cases. Test invalid input, missing auth, expired tokens.
  • Skipping HTTPS: Even internal APIs should use HTTPS to prevent man-in-the-middle attacks.

Why It Matters

API security isn’t a feature—it’s a foundation. A single vulnerability can expose customer data, cost you reputation, or trigger regulatory fines (GDPR, HIPAA, PCI-DSS). The controls in this guide are well-established, battle-tested, and expected by security auditors.

Start with authentication and rate limiting, add input validation, then layer in access control and logging. Monitor continuously, rotate credentials regularly, and keep dependencies patched.

Your API is a gateway to your business. Guard it accordingly.

This post was generated with AI assistance and reviewed for accuracy.