Back to Blog

API Security Best Practices: Authentication, Authorization, and OWASP Top 10

API security best practices in 2026 — JWT authentication, OAuth 2.0, rate limiting, input validation, OWASP API Security Top 10, and production-grade TypeScript

Viprasol Tech Team
March 28, 2026
14 min read

API Security Best Practices: Authentication, Authorization, and OWASP Top 10

APIs are the most attacked surface in modern applications. The 2024 Verizon Data Breach Investigations Report found that APIs were involved in 83% of web application breaches. Most of those breaches exploited issues that are entirely preventable with standard techniques.

This guide covers the full API security stack: authentication patterns, authorization design, input validation, rate limiting, and the OWASP API Security Top 10 with practical mitigation code for each.


Authentication: JWT vs. Sessions vs. API Keys

JWT (JSON Web Tokens)

JWTs are signed tokens that contain claims. The server doesn't need to store session state — the token is self-contained.

import jwt from 'jsonwebtoken';
import { z } from 'zod';

const JWT_SECRET = process.env.JWT_SECRET!; // Must be ≥32 bytes, rotated regularly
const ACCESS_TOKEN_TTL = '15m';  // Short-lived — this is critical
const REFRESH_TOKEN_TTL = '7d';

interface TokenPayload {
  sub: string;       // User ID
  email: string;
  role: string;
  iat: number;       // Issued at
  exp: number;       // Expiry
}

// Issue token pair on login
function issueTokens(userId: string, email: string, role: string) {
  const accessToken = jwt.sign(
    { sub: userId, email, role },
    JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_TTL, algorithm: 'HS256' }
  );

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_TTL, algorithm: 'HS256' }
  );

  return { accessToken, refreshToken };
}

// Verify and decode
function verifyAccessToken(token: string): TokenPayload {
  try {
    return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      throw new Error('TOKEN_EXPIRED');
    }
    throw new Error('TOKEN_INVALID');
  }
}

// Auth middleware
export const authenticate = async (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid Authorization header' });
  }

  const token = authHeader.slice(7);
  try {
    const payload = verifyAccessToken(token);
    req.user = payload;
    next();
  } catch (err: any) {
    const status = err.message === 'TOKEN_EXPIRED' ? 401 : 401;
    res.status(status).json({ error: err.message });
  }
};

JWT security rules:

  • Access tokens expire in 15 minutes — not hours, not days
  • Refresh tokens expire in 7 days and are stored in an HTTP-only cookie (not localStorage)
  • Use HS256 (HMAC) or RS256 (RSA) — never none
  • Rotate the JWT secret periodically; have a rotation procedure

API Key Authentication

For machine-to-machine and partner integrations:

import crypto from 'crypto';

// Generate API key
function generateApiKey(): { key: string; hash: string } {
  const key = `vip_${crypto.randomBytes(32).toString('hex')}`;
  const hash = crypto.createHash('sha256').update(key).digest('hex');
  return { key, hash }; // Store hash in DB, return key once to user
}

// Verify API key (hash comparison — no plain-text storage)
async function verifyApiKey(providedKey: string): Promise<ApiClient | null> {
  const hash = crypto.createHash('sha256').update(providedKey).digest('hex');
  
  const client = await db('api_keys')
    .where({ key_hash: hash, revoked: false })
    .first();

  if (!client) return null;

  // Update last used (async — don't block the response)
  db('api_keys').where({ id: client.id }).update({ last_used_at: new Date() }).catch(console.error);

  return client;
}

Authorization: RBAC and Resource-Level Checks

Authentication answers "who are you?" Authorization answers "what can you do?"

Role-Based Access Control (RBAC)

type Permission =
  | 'users:read'
  | 'users:write'
  | 'billing:read'
  | 'billing:write'
  | 'admin:all';

const ROLE_PERMISSIONS: Record<string, Permission[]> = {
  viewer: ['users:read'],
  member: ['users:read', 'users:write'],
  billing_admin: ['users:read', 'billing:read', 'billing:write'],
  admin: ['users:read', 'users:write', 'billing:read', 'billing:write', 'admin:all'],
};

function can(userRole: string, permission: Permission): boolean {
  return ROLE_PERMISSIONS[userRole]?.includes(permission) ?? false;
}

// Authorization middleware factory
function requirePermission(permission: Permission) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
    if (!can(req.user.role, permission)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Usage
router.get('/admin/users', authenticate, requirePermission('admin:all'), listUsers);
router.post('/users/:id', authenticate, requirePermission('users:write'), updateUser);

Resource-Level Authorization (CRITICAL — Prevents BOLA)

Always verify the requesting user owns or has access to the specific resource:

// ❌ BAD: Only checks authentication, not ownership
app.get('/orders/:orderId', authenticate, async (req, res) => {
  const order = await db('orders').where({ id: req.params.orderId }).first();
  res.json(order); // Any authenticated user can see any order!
});

// ✅ GOOD: Verify resource ownership
app.get('/orders/:orderId', authenticate, async (req, res) => {
  const order = await db('orders')
    .where({ 
      id: req.params.orderId,
      user_id: req.user.sub,  // Scope to authenticated user's orders
    })
    .first();

  if (!order) {
    // Return 404, not 403 — don't reveal whether the resource exists
    return res.status(404).json({ error: 'Order not found' });
  }

  res.json(order);
});

This is OWASP API1:2023 — Broken Object Level Authorization (BOLA). It's the #1 API vulnerability in production systems.


🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

OWASP API Security Top 10 (2023) Mitigations

API1: Broken Object Level Authorization (BOLA)

Always scope queries by user_id (or equivalent). Never trust the ID in the URL alone.

API2: Broken Authentication

// Brute-force protection on login
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 5,                     // 5 attempts per IP
  message: { error: 'Too many login attempts, try again in 15 minutes' },
  standardHeaders: true,
  legacyHeaders: false,
  // Additional: lock account after N failures (track in DB)
});

app.post('/auth/login', loginLimiter, handleLogin);

API3: Broken Object Property Level Authorization

Return only the fields the user should see. Never return entire DB rows:

// ❌ BAD: Returns password_hash, internal flags, admin fields
const user = await db('users').where({ id: userId }).first();
res.json(user);

// ✅ GOOD: Explicit field selection
const user = await db('users')
  .select('id', 'email', 'display_name', 'created_at', 'plan')
  .where({ id: userId })
  .first();
res.json(user);

API4: Unrestricted Resource Consumption

// Rate limiting per API key / user (not just per IP)
const apiRateLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute window
  max: 100,
  keyGenerator: (req) => req.user?.sub ?? req.ip,  // Rate limit per user, not IP
  handler: (req, res) => {
    res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
    });
  },
});

// Also limit request body size
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));

API5: Broken Function Level Authorization

Separate admin endpoints into a different router with explicit admin-only middleware:

// Admin router — every route requires admin role
const adminRouter = express.Router();
adminRouter.use(authenticate);
adminRouter.use(requirePermission('admin:all'));

adminRouter.get('/users', listAllUsers);
adminRouter.delete('/users/:id', deleteUser);
adminRouter.post('/users/:id/ban', banUser);

// Regular router — user-scoped endpoints
app.use('/api/v1', userRouter);
app.use('/api/v1/admin', adminRouter);  // Separate prefix

API8: Security Misconfiguration

// Security headers — use helmet
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: true,
  hsts: { maxAge: 31536000, includeSubDomains: true },
  noSniff: true,
  xssFilter: true,
}));

// CORS — explicit whitelist, never wildcard for authenticated APIs
import cors from 'cors';

const allowedOrigins = [
  'https://app.yourproduct.com',
  'https://yourproduct.com',
  ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS: Origin not allowed'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));

API10: Unsafe Consumption of APIs

When consuming external APIs, validate responses:

import { z } from 'zod';

const ExternalUserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string().max(255),
  // Don't pass unknown fields through to your system
});

async function fetchExternalUser(id: string): Promise<ExternalUser> {
  const response = await fetch(`https://partner-api.com/users/${id}`, {
    headers: { Authorization: `Bearer ${process.env.PARTNER_API_KEY}` },
    signal: AbortSignal.timeout(5000),  // 5-second timeout
  });

  if (!response.ok) {
    throw new Error(`Partner API error: ${response.status}`);
  }

  const raw = await response.json();
  return ExternalUserSchema.parse(raw);  // Validates AND strips unknown fields
}

Input Validation

Every external input is untrusted. Validate at the API boundary:

import { z } from 'zod';

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1).max(1000),
  shippingAddress: z.object({
    line1: z.string().min(1).max(200),
    city: z.string().min(1).max(100),
    country: z.string().length(2),  // ISO country code
    postalCode: z.string().regex(/^[A-Z0-9\s-]{3,10}$/i),
  }),
  couponCode: z.string().regex(/^[A-Z0-9]{6,12}$/).optional(),
});

app.post('/orders', authenticate, async (req, res) => {
  const result = CreateOrderSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.flatten(),
    });
  }

  const order = await orderService.create(req.user.sub, result.data);
  res.status(201).json(order);
});

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Logging and Monitoring

// Log security events for audit and incident response
async function logSecurityEvent(event: {
  type: 'login_failed' | 'access_denied' | 'rate_limit_hit' | 'suspicious_request';
  userId?: string;
  ip: string;
  details: Record<string, unknown>;
}): Promise<void> {
  await db('security_events').insert({
    ...event,
    occurred_at: new Date(),
  });

  // Alert on high-frequency suspicious activity
  if (event.type === 'login_failed') {
    const recentFailures = await db('security_events')
      .where({ type: 'login_failed', ip: event.ip })
      .where('occurred_at', '>', new Date(Date.now() - 60000))
      .count('id as count');

    if (Number(recentFailures[0].count) > 10) {
      await alertService.send(`Brute force attempt from IP ${event.ip}`);
    }
  }
}

Security Implementation Costs

TaskInvestment
API security audit$5,000–$15,000
Auth system implementation (JWT + refresh)$5,000–$12,000
RBAC + resource-level authorization$8,000–$20,000
Rate limiting + DDoS protection$3,000–$8,000
Full API security hardening$20,000–$50,000
Penetration test (external)$5,000–$20,000

Working With Viprasol

We build secure APIs from the ground up — or audit and harden existing ones. Our security implementations are OWASP-aligned and include authentication, authorization, input validation, rate limiting, and monitoring.

API security review →
API Development Services →
Web Development Services →


See Also


Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.