Ismat Samadov
  • Tags
  • About
17 min read/1 views

API Security Mistakes Every Junior Dev Makes

Seven API security mistakes I see junior devs make constantly, with TypeScript code showing what is wrong and how to fix it.

SecurityAPIWeb DevelopmentTypeScript

Related Articles

The xz-utils Backdoor Was a Preview — Software Supply Chain Security Is Broken

13 min read

Python 3.14 T-Strings Will Change How You Write Python Forever

16 min read

Temporal for Backend Developers: Durable Execution Makes Complex Backends Boring

18 min read

Enjoyed this article?

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

On this page

  • Mistake #1: Broken Object Level Authorization (BOLA)
  • Mistake #2: Returning Too Much Data
  • Mistake #3: JWT Authentication Done Wrong
  • Mistake #4: No Rate Limiting
  • Mistake #5: SQL Injection Through Poor Input Handling
  • Mistake #6: Leaking Error Details in Production
  • Mistake #7: No Input Validation
  • The Security Checklist
  • The AI Factor
  • Common Vulnerability Severity Comparison
  • What I Actually Think
  • Sources

© 2026 Ismat Samadov

RSS

I shipped an API to production in 2021 that had no rate limiting, returned full user objects including hashed passwords, and validated exactly zero inputs. It was a Node/Express app for an internal tool. I figured internal meant safe. A penetration tester found it in four hours and filed eleven findings against it. Eleven.

That experience rewired how I think about API security. Not because I read a book about OWASP. Because I got burned.

If you're a junior developer shipping APIs right now, you're probably making the same mistakes I made. Not because you're careless — because nobody teaches this stuff properly. Bootcamps skip it. University courses gloss over it. Most tutorials show you how to build a REST API in 20 minutes and never mention authentication, authorization, or input validation.

The data backs this up. API vulnerability exploitation grew 181% in 2025, and APIs now account for 17% of all reported vulnerabilities — 11,053 out of 67,058. Here's what really gets me: 97% of those API vulnerabilities are exploitable with a single HTTP request. One request. Not a sophisticated multi-step attack chain. One curl command and your data is gone.

99% of organizations experienced API security incidents in the past year. API breaches cost enterprises up to $186 billion annually. API-related breaches account for over 90% of all web-based attacks.

This isn't a niche problem. This is the problem.

I'm going to walk you through the seven mistakes I see junior developers make constantly. Each one comes with code showing what's wrong and how to fix it. By the end, you'll have a security checklist you can pin next to your monitor.


Mistake #1: Broken Object Level Authorization (BOLA)

This is the number one vulnerability on the OWASP API Security Top 10, and it's embarrassingly simple to exploit. BOLA and injection attacks together account for over one-third of all API security incidents.

Here's what it looks like. You build an endpoint to fetch user profiles:

// WRONG: No authorization check
app.get('/api/users/:id', async (req, res) => {
  const user = await db.query.users.findFirst({
    where: eq(users.id, parseInt(req.params.id)),
  })
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(user)
})

What's wrong? Everything. If I'm logged in as user 1234 and I change the URL to /api/users/1235, I get someone else's profile. That's it. That's the whole attack. Change a number in the URL.

You might think "nobody would guess another user's ID." They don't have to guess. IDs are often sequential integers. Even with UUIDs, IDs leak everywhere — in URLs, in email links, in API responses that include references to other users.

Here's the fix:

// RIGHT: Always verify the requesting user owns the resource
app.get('/api/users/:id', authenticate, async (req, res) => {
  const requestedId = parseInt(req.params.id)

  // The authenticated user can only access their own profile
  // Admins can access any profile
  if (req.user.id !== requestedId && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' })
  }

  const user = await db.query.users.findFirst({
    where: eq(users.id, requestedId),
  })
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(sanitizeUser(user))
})

The rule is simple: every single endpoint that accesses a resource by ID must verify that the authenticated user has permission to access that specific resource. Not "is the user logged in?" but "is this user allowed to see this specific thing?"

I've seen codebases where the authentication middleware exists but the authorization check doesn't. They verify that you have a valid token. They never check whether that token belongs to someone who should see the data. Those are two completely different questions.


Mistake #2: Returning Too Much Data

This one is subtle because it doesn't feel like a security vulnerability. But over-exposing data in API responses is one of the most common mistakes I see in code reviews.

// WRONG: Returning the full database record
app.get('/api/users/:id', authenticate, authorize, async (req, res) => {
  const user = await db.query.users.findFirst({
    where: eq(users.id, parseInt(req.params.id)),
  })
  // This sends EVERYTHING: password hash, email, SSN, internal notes...
  res.json(user)
})

When you return the raw database row, you send everything. Password hashes, internal flags, email addresses, phone numbers, sometimes even credit card tokens if your schema is messy. The frontend might only display the username and avatar, but all that data is in the network tab for anyone to see.

// RIGHT: Explicitly select only the fields the client needs
app.get('/api/users/:id', authenticate, authorize, async (req, res) => {
  const user = await db.query.users.findFirst({
    where: eq(users.id, parseInt(req.params.id)),
    columns: {
      id: true,
      username: true,
      displayName: true,
      avatarUrl: true,
      createdAt: true,
    },
  })
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(user)
})

You can also create a serialization function that you use everywhere:

// A reusable sanitizer for user objects
function sanitizeUser(user: User): PublicUser {
  return {
    id: user.id,
    username: user.username,
    displayName: user.displayName,
    avatarUrl: user.avatarUrl,
    createdAt: user.createdAt,
  }
}

The principle: never send data the client didn't ask for. Be explicit about every field in every response. If a field shouldn't leave the server, don't include it. Don't rely on the frontend to hide it.


Mistake #3: JWT Authentication Done Wrong

Broken authentication was the culprit in 52% of API security incidents. 47% of APIs skip authentication altogether, and 59% of API vulnerabilities require no authentication to exploit.

Here's the JWT mistake I see most often:

// WRONG: Multiple JWT mistakes in one function
import jwt from 'jsonwebtoken'

const SECRET = 'my-secret-key' // Hardcoded weak secret

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body
  const user = await findUserByEmail(email)

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const token = jwt.sign(
    {
      userId: user.id,
      email: user.email,
      ssn: user.ssn,          // Sensitive data in JWT!
      role: user.role,
      passwordHash: user.passwordHash, // WHY
    },
    SECRET,
    { expiresIn: '365d' }     // Token valid for a YEAR
  )

  res.json({ token })
})

// Middleware that only checks if token exists
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: 'No token' })

  try {
    req.user = jwt.verify(token, SECRET)  // No issuer/audience validation
    next()
  } catch {
    res.status(401).json({ error: 'Invalid token' })
  }
}

I count six things wrong there:

  1. Hardcoded secret — it's in the source code, which means it's in git, which means everyone who clones the repo can forge tokens
  2. Weak secret — "my-secret-key" can be brute-forced in minutes
  3. Sensitive data in the payload — JWTs are base64-encoded, not encrypted. Anyone can decode the payload. SSN and password hash should never be in a token
  4. 365-day expiration — if a token leaks, the attacker has a year to use it
  5. HS256 with a weak secret — HS256 is fine for symmetric signing but only if the secret is truly random and long. For most production systems, use RS256 or ES256 with key pairs
  6. No issuer/audience validation — without checking iss and aud claims, tokens from other services could be accepted

Here's what it should look like:

// RIGHT: Secure JWT implementation
import jwt from 'jsonwebtoken'
import fs from 'fs'

// RS256 with asymmetric keys, loaded from environment
const PRIVATE_KEY = fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH!)
const PUBLIC_KEY = fs.readFileSync(process.env.JWT_PUBLIC_KEY_PATH!)

const TOKEN_ISSUER = 'https://api.yourapp.com'
const TOKEN_AUDIENCE = 'https://yourapp.com'

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body
  const user = await findUserByEmail(email)

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    // Don't reveal whether the email exists
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  // Access token: short-lived, minimal claims
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: '15m',
      issuer: TOKEN_ISSUER,
      audience: TOKEN_AUDIENCE,
    }
  )

  // Refresh token: longer-lived, stored securely
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: '7d',
      issuer: TOKEN_ISSUER,
      audience: TOKEN_AUDIENCE,
    }
  )

  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  })

  res.json({ accessToken, expiresIn: 900 })
})

// Middleware that validates everything
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: 'No token' })

  try {
    const decoded = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: TOKEN_ISSUER,
      audience: TOKEN_AUDIENCE,
    })
    req.user = decoded
    next()
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' })
    }
    res.status(401).json({ error: 'Invalid token' })
  }
}

The key differences: RS256 with asymmetric keys (the public key can verify tokens without being able to create them), 15-minute access token expiration, no sensitive data in the payload, full claim validation, and refresh tokens stored in httpOnly cookies where JavaScript can't access them.

Here's a quick comparison:

PracticeWrongRight
AlgorithmHS256 with weak secretRS256 or ES256 with key pair
Secret storageHardcoded in sourceEnvironment variable / file
Access token TTL365 days15-30 minutes
Payload dataEmail, SSN, password hashUser ID and role only
Claim validationNoneiss, aud, exp, nbf
Refresh tokensNot usedhttpOnly secure cookie

Mistake #4: No Rate Limiting

This one is the easiest to fix and the most commonly skipped. I've reviewed dozens of production APIs that had zero rate limiting on any endpoint. Not even on the login endpoint.

Without rate limiting, an attacker can:

  • Brute-force passwords by trying millions of combinations
  • Scrape your entire database through paginated endpoints
  • Run up your cloud bill by hitting expensive computation endpoints
  • Denial-of-service your API with sheer request volume
// WRONG: No rate limiting at all
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body
  const user = await findUserByEmail(email)
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }
  // ... issue token
})

An attacker can hit this endpoint thousands of times per second. bcrypt is intentionally slow (that's the point), so each request also ties up server resources. You get brute-forced and DDoS'd at the same time.

// RIGHT: Rate limiting with Redis
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL!)

// General API rate limit
const apiLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                   // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, try again later' },
})

// Strict limiter for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000,
  max: 5,                     // Only 5 login attempts per 15 min
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => {
    // Rate limit by IP + email combo to prevent distributed attacks
    return `${req.ip}:${req.body?.email || 'unknown'}`
  },
  message: { error: 'Too many login attempts, try again later' },
})

app.use('/api/', apiLimiter)
app.post('/api/login', authLimiter, loginHandler)
app.post('/api/register', authLimiter, registerHandler)
app.post('/api/forgot-password', authLimiter, forgotPasswordHandler)

For production systems, you want to enforce rate limiting at the edge (your API gateway or CDN) before requests even hit your application servers. Cloudflare, AWS API Gateway, and Kong all have built-in rate limiting. The application-level rate limiting shown above is your second line of defense.


Mistake #5: SQL Injection Through Poor Input Handling

You'd think SQL injection would be a solved problem in 2026. It's not. 98% of API vulnerabilities are easy or trivial to exploit, and injection attacks remain one of the top vectors.

Most modern ORMs prevent SQL injection by default. The problem happens when developers drop down to raw SQL for complex queries:

// WRONG: String interpolation in SQL
app.get('/api/search', authenticate, async (req, res) => {
  const { query, sortBy } = req.query

  // Direct string interpolation = SQL injection
  const results = await db.execute(
    `SELECT * FROM products WHERE name LIKE '%${query}%' ORDER BY ${sortBy}`
  )

  res.json(results)
})

If someone sends query='; DROP TABLE products; --, your products table is gone. If they send sortBy=name; DELETE FROM users; --, your users are gone. This is not theoretical. Automated scanners test for this pattern against every public API.

// RIGHT: Parameterized queries and input validation
import { z } from 'zod'
import { sql } from 'drizzle-orm'

const searchSchema = z.object({
  query: z.string().min(1).max(100),
  sortBy: z.enum(['name', 'price', 'created_at']),
  order: z.enum(['asc', 'desc']).default('asc'),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).default(0),
})

app.get('/api/search', authenticate, async (req, res) => {
  const parsed = searchSchema.safeParse(req.query)
  if (!parsed.success) {
    return res.status(400).json({
      error: 'Invalid parameters',
      details: parsed.error.flatten(),
    })
  }

  const { query, sortBy, order, limit, offset } = parsed.data

  // Parameterized query — the database driver handles escaping
  const results = await db.execute(
    sql`SELECT id, name, price, created_at
         FROM products
         WHERE name ILIKE ${'%' + query + '%'}
         ORDER BY ${sql.identifier(sortBy)} ${sql.raw(order)}
         LIMIT ${limit} OFFSET ${offset}`
  )

  res.json(results)
})

The key changes:

  1. Zod schema validation — the sortBy field can only be one of the allowed column names. No arbitrary SQL can sneak through.
  2. Parameterized queries — the query value is passed as a parameter, not interpolated into the SQL string. The database driver escapes it properly.
  3. Allowlisted sort columns — instead of accepting any string for sortBy, we restrict it to an explicit enum of safe column names.
  4. Bounded pagination — limit is capped at 100 to prevent someone from doing ?limit=1000000 and dumping your entire table.

Mistake #6: Leaking Error Details in Production

When your API throws an error in development, you want all the details. Stack traces, SQL queries, variable values — the more context the better. But if that same level of detail reaches production, you're handing attackers a roadmap.

// WRONG: Exposing internal errors to clients
app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({
    error: err.message,
    stack: err.stack,
    query: err.query,     // The actual SQL query that failed
    code: err.code,       // Database error code
  })
})

A stack trace reveals your file structure, your ORM, your database schema, your Node version, your dependency versions. An attacker now knows exactly which CVEs to try against your stack.

// RIGHT: Generic errors in production, detailed in development
import { v4 as uuidv4 } from 'uuid'

app.use((err, req, res, next) => {
  const errorId = uuidv4()

  // Log everything server-side for debugging
  console.error({
    errorId,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
    timestamp: new Date().toISOString(),
  })

  // Send minimal info to the client
  if (process.env.NODE_ENV === 'production') {
    res.status(500).json({
      error: 'Internal server error',
      errorId, // So support can look up the full error
    })
  } else {
    // In development, show everything
    res.status(500).json({
      error: err.message,
      stack: err.stack,
      errorId,
    })
  }
})

The errorId pattern is one of the most useful things I've adopted. The client gets a UUID they can send to support. Support can search the server logs for that UUID and find the full stack trace, the user who triggered it, the exact request that caused it. Full debugging context server-side, zero information leakage client-side.


Mistake #7: No Input Validation

This one ties everything together. Almost every vulnerability above is made worse by missing input validation. If you don't validate that an ID is actually a number, your BOLA check might break. If you don't validate that an email is an email, your login flow might do unexpected things. If you don't validate request body structure at all, anything goes.

// WRONG: Trusting client input completely
app.post('/api/users', authenticate, async (req, res) => {
  // Whatever the client sends goes straight to the database
  const user = await db.insert(users).values(req.body).returning()
  res.json(user)
})

If someone sends { "role": "admin", "verified": true, "credits": 999999 } in the request body, and those columns exist in your schema, congratulations — you just gave them admin access and a million credits.

// RIGHT: Strict schema validation on every endpoint
import { z } from 'zod'

const createUserSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(30, 'Username must be under 30 characters')
    .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, hyphens, and underscores'),
  email: z.string()
    .email('Invalid email address')
    .max(255),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .max(128, 'Password must be under 128 characters'),
})

app.post('/api/users', async (req, res) => {
  const parsed = createUserSchema.safeParse(req.body)
  if (!parsed.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: parsed.error.flatten(),
    })
  }

  // Only the validated fields are used — nothing else from req.body
  const { username, email, password } = parsed.data
  const passwordHash = await bcrypt.hash(password, 12)

  const user = await db.insert(users).values({
    username,
    email,
    passwordHash,
    role: 'user',       // Hardcoded, never from client input
    verified: false,     // Hardcoded, never from client input
  }).returning({
    id: users.id,
    username: users.username,
    email: users.email,
  })

  res.status(201).json(user)
})

Validate every input. On every endpoint. Every time. No exceptions. Zod is my preferred validation library for TypeScript because it infers types from schemas, so you get both runtime validation and compile-time type safety from one definition.


The Security Checklist

Print this. Pin it next to your monitor. Go through it before every PR that touches an API endpoint.

Authentication:

  • Every endpoint that should be protected has authentication middleware
  • Tokens are validated (not just checked for existence)
  • JWT secrets are loaded from environment variables, never hardcoded
  • Access tokens expire in 15-30 minutes
  • Refresh tokens are stored in httpOnly secure cookies
  • Failed login doesn't reveal whether the email exists

Authorization:

  • Every endpoint that accesses a resource by ID checks ownership
  • Admin-only endpoints verify the admin role
  • Users can't escalate their own permissions through the API

Input validation:

  • Every endpoint validates its input with a schema (Zod, Joi, etc.)
  • Sort/filter fields are restricted to an explicit allowlist
  • Pagination has a maximum page size
  • File uploads are validated for type and size
  • No raw user input is interpolated into SQL, shell commands, or templates

Response data:

  • Only required fields are returned in responses
  • No password hashes, tokens, or internal IDs leak in responses
  • Error responses in production don't include stack traces
  • Error responses don't reveal database structure

Rate limiting:

  • Login/register/password-reset endpoints have strict rate limits
  • General API has per-user and per-IP rate limits
  • Expensive operations (search, export, report generation) have lower limits

Headers and transport:

  • HTTPS is enforced (HSTS header)
  • CORS is configured to allow only known origins
  • Content-Type is validated on incoming requests
  • Security headers are set (X-Content-Type-Options, X-Frame-Options)

The AI Factor

One more thing. AI is making this worse, not better.

AI-related threats against APIs soared 400% year over year, from 439 to 2,185 incidents. 36% of AI-related vulnerabilities involve APIs. As companies rush to ship AI features — chatbots, recommendation engines, content generation — they're bolting new API endpoints onto existing systems without the same security review process.

AI endpoints are especially risky because they often:

  • Accept large, unstructured text input (prompt injection risk)
  • Make calls to external services (SSRF risk)
  • Return generated content that might include internal data (data leakage risk)
  • Are expensive to run (no rate limiting = massive bills)

If you're building AI-powered APIs, every rule in this article applies double. Validate inputs even harder. Rate limit even tighter. Monitor costs per user.


Common Vulnerability Severity Comparison

Here's how these mistakes stack up by impact and how often I see them in the wild:

VulnerabilityOWASP RankExploitabilityImpactHow Common
BOLA#1One requestData breachVery common
Broken Auth#2One requestFull takeoverVery common
Over-exposed Data#3 (BOPLA)One requestData leakExtremely common
No Rate Limiting#4AutomatedDDoS / brute forceExtremely common
SQL Injection#10 (Injection)One requestFull databaseCommon
Error Leakage#7 (Misconfig)One requestReconExtremely common
No Input ValidationCuts across allVariesVariesNearly universal

40% of organizations lack full visibility into their API attack surface. You can't secure what you can't see, but you can at least secure what you're building right now.


What I Actually Think

Here's my honest take on API security as someone who's been on both sides of it — building vulnerable APIs and later fixing them.

Most API security failures aren't sophisticated. They're boring. An endpoint that doesn't check authorization. A JWT that never expires. A SQL query built with string concatenation. These aren't exotic zero-days. They're mistakes that a code review should catch but doesn't because nobody on the team knows what to look for.

The security industry loves to make this stuff sound complicated. They publish 50-page whitepapers and sell six-figure scanning tools. And sure, there are genuinely complex attack patterns out there. But the stuff that actually gets exploited day-to-day? It's the seven mistakes in this article. Basic stuff that every developer should learn in their first year on the job.

My controversial opinion: if your team just did input validation, authorization checks, and rate limiting on every endpoint, you'd eliminate about 80% of your API attack surface. You don't need a WAF. You don't need an API security platform. You need developers who check if the user is allowed to see the thing they're requesting.

I also think the industry under-indexes on code review for security. A 15-minute code review by someone who knows the OWASP API Top 10 catches more vulnerabilities than a $200/month scanning tool. Scanners are good at finding known CVEs in dependencies. They're terrible at finding business logic flaws like "this endpoint lets any authenticated user delete any other user's data."

Start with the basics. Validate inputs. Check authorization on every resource. Don't return data the client doesn't need. Use short-lived tokens. Rate limit everything. Handle errors without leaking internals. That's 90% of API security right there. The other 10% is the stuff you hire a security team for.


Sources

  1. Inside Modern API Attacks: 2026 API ThreatStats Report — Wallarm
  2. State of API Security 2026 Report — 42Crunch
  3. API Security Trends — Astra
  4. Data Breach Statistics 2025 — DeepStrike
  5. REST API Security Best Practices — Levo
  6. APIs are the Single Most Exploited Attack Surface — BusinessWire
  7. The API Threat Report 2025 — CybelAngel
  8. API Security Authentication Pitfalls — Cequence
  9. OWASP API Security Top 10