August 27, 2025

API Security: A Developer Play

Photo of the author of the blog post
Buchi Reddy B

CEO & Founder at LEVO

Photo of the author of the blog post
Levo API Security Research Panel

Research Team

API Security: A Developer Play

TL;DR

  • Start every service with contract-first design, short-lived tokens, object-level auth, schema validation, rate limits, replay guards, and evidence-by-default.
  • Wire small, reliable CI gates and portable policies at edge and service.
  • First 30 days, ship 5 changes: API-BOM, JWT iss and aud checks, ownership checks on money and identity flows, write-route limits with request normalization, webhook signatures with a 5-minute window.

Who this is for, and how to use it

Developers, tech leads, and staff ICs building and running APIs. Use this to scaffold new services, harden legacy ones, and standardize controls across languages. Skim the Quick Wins, then drop the code snippets into your repo. Treat the PR Checklist as the acceptance criteria for every API change.

60-minute Quick Wins

  1. Add token checks: verify iss, aud, exp, and signature in your middleware.
  2. Add object ownership: check tenant and subject on every read and write of sensitive resources.
  3. Block overposting: strict request schemas with additionalProperties: false.
  4. Turn on limits: soft read limits, stricter write limits, plus request normalization.
  5. Verify webhooks: HMAC signature, timestamp, five-minute replay window, idempotency keys.

Threat model for builders

  • IDOR/BOLA: object authorization missing or inconsistent.
  • Mass assignment: extra fields accepted and honored.
  • Token misuse: missing aud/iss check, long TTLs, reuse across services.
  • Parser exhaustion: deeply nested or oversized payloads.
  • Webhook replay/spoof: no signature, long replay window.
  • Version drift: old routes left live, undocumented endpoints.

Fix with contract-first design, strict types, portable policies, and negative tests in CI.

Day-0 service scaffold

Contract (OpenAPI 3.1, REST)

YAML
openapi: 3.1.0
info: { title: Orders API, version: "1.0.0" }
components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://idp.example.com/oauth2/token
          scopes:
            orders.read: "Read orders"
            orders.write: "Create or update orders"
  schemas:
    OrderCreate:
      type: object
      additionalProperties: false
      required: [sku, qty]
      properties:
        sku: { type: string, minLength: 1, maxLength: 64 }
        qty: { type: integer, minimum: 1, maximum: 1000 }
security: [{ oauth2: [orders.read] }]
paths:
  /v1/orders:
    post:
      security: [{ oauth2: [orders.write] }]
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/OrderCreate" } }
      responses:
        "201": { description: Created }
        "400": { description: Schema violation }
        "401": { description: Bad token }
        "403": { description: Forbidden }

Token validation and ownership (Node, Express)

JS
import express from "express";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

const app = express();
app.use(express.json({ limit: "512kb" }));

const ISSUER = "https://idp.example.com/";
const AUD = "api://orders";
const jwks = jwksClient({ jwksUri: `${ISSUER}.well-known/jwks.json` });

function getKey(header, cb){ jwks.getSigningKey(header.kid, (e, key)=>cb(e, key.getPublicKey())); }
function requireJwt(req, res, next){
  const token = (req.headers.authorization || "").replace(/^Bearer /,"");
  if(!token) return res.status(401).json({ error: "missing_token" });
  jwt.verify(token, getKey, { algorithms:["RS256"], issuer: ISSUER }, (err, dec)=>{
    if(err || dec.aud !== AUD) return res.status(401).json({ error: "invalid_token" });
    req.user = dec; next();
  });
}

// Ownership helper
function owns(account, user){ return account.tenant_id === user.tid && account.owner === user.sub; }

app.get("/v1/accounts/:id", requireJwt, async (req,res)=>{
  const acct = await db.accounts.findById(req.params.id);
  if(!acct || !owns(acct, req.user)) return res.status(403).json({ error: "forbidden" });
  res.json({ id: acct.id, email: acct.email });
});

app.listen(3000);

Strict schema with overposting block (Python, FastAPI)

PYTHON
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

class OrderCreate(BaseModel):
    sku: str = Field(min_length=1, max_length=64)
    qty: int = Field(gt=0, le=1000)

    class Config:
        extra = "forbid"  # block unexpected fields (mass assignment)

app = FastAPI()

def verify_token(auth: str):
    # Verify JWT 'iss','aud','exp' and return {'sub':..., 'tid':...}
    ...

@app.post("/v1/orders")
def create_order(body: OrderCreate, authorization: str = Header(None)):
    if not authorization: raise HTTPException(401, "missing_token")
    user = verify_token(authorization.replace("Bearer ",""))
    # example tenant policy
    if user["tid"] != "tenant-123": raise HTTPException(403, "forbidden")
    return {"status":"created"}

Webhook signature verify and replay window (Node)

JS
import crypto from "crypto";
const WINDOW_MS = 5 * 60 * 1000;

function verifySig(req, secret){
  const sig = req.headers["x-signature"];         // "t=Unix,s=hex(hmac_sha256(t.body,secret))"
  if(!sig) return false;
  const parts = Object.fromEntries(sig.split(",").map(p=>p.split("=")));
  const ts = Number(parts.t), mac = parts.s;
  if(Math.abs(Date.now() - ts) > WINDOW_MS) return false;
  const h = crypto.createHmac("sha256", secret).update(`${ts}.${req.rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(h,"hex"), Buffer.from(mac,"hex"));
}

Edge guardrails (NGINX snippet)

NGINX
# Rate limit: 10 writes/sec per IP, burst 20
limit_req_zone $binary_remote_addr zone=write:10m rate=10r/s;

server {
  listen 443 ssl;
  client_max_body_size 1m;

  location /v1/ {
    # If using jwt module, otherwise delegate to an auth service
    # auth_jwt "secured";
    # auth_jwt_key_file /etc/nginx/jwks.json;

    limit_req zone=write burst=20 nodelay;
    proxy_set_header X-Request-Normalized "1"; # pair with normalization in app
    proxy_pass http://orders_upstream;
  }
}

Request normalization ideas

  • Trim whitespace and normalize casing where safe.
  • Sort object keys and de-duplicate array members that should be unique.
  • Collapse repeated query parameters.
  • Bound array lengths and string sizes.
  • Canonicalize time and number formats.

GraphQL hardening

  • Persisted queries only.
  • Depth and cost limits.
  • Field-level authorization for sensitive data.
  • Disable introspection in production.

Apollo persisted queries

JS
import { ApolloServer } from "@apollo/server";
import { createPersistedQueryPlugin } from "@apollo/server-plugin-persisted-queries";

const server = new ApolloServer({
  typeDefs, resolvers,
  plugins: [createPersistedQueryPlugin()]
});

gRPC best practices

  • mTLS for service-to-service.
  • Method allowlists at the proxy.
  • Deadlines on all calls, max message size.
  • Method-level RBAC and context propagation.

Go unary interceptor sketch

GO
func authzUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
  md, _ := metadata.FromIncomingContext(ctx)
  tok := strings.TrimPrefix(md.Get("authorization")[0], "Bearer ")
  sub, tid, err := verifyJWT(tok) // verify iss,aud,exp
  if err != nil { return nil, status.Error(codes.Unauthenticated, "bad token") }
  if !allowed(info.FullMethod, sub, tid) { return nil, status.Error(codes.PermissionDenied, "forbidden") }
  return next(ctx, req)
}

CI gates that do not flake (GitHub Actions)

YAML
name: api-ci
on: [push, pull_request]
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Lint OpenAPI and bundle
        run: npm run openapi:lint && npm run openapi:bundle
      - name: Negative tests (curl)
        run: ./scripts/neg-tests.sh   # IDOR, overposting, wrong-aud, expired
      - name: GraphQL safety
        run: npm run gql:persist && npm run gql:check-depth-cost
      - name: Fuzz quick
        run: npm run fuzz:payloads

Example negative tests (bash)

BASH
# 1) Wrong audience should 401
curl -s -o /dev/null -w "%{http_code}\n" \
 -H "Authorization: Bearer $WRONG_AUD" https://api.example.com/v1/orders | grep -q "^401$"

# 2) IDOR should 403
curl -s -o /dev/null -w "%{http_code}\n" \
 -H "Authorization: Bearer $TOKEN_A" https://api.example.com/v1/accounts/${OTHER_USER} | grep -q "^403$"

# 3) Overposting should 400 or 422
curl -s -o /dev/null -w "%{http_code}\n" \
 -H "Authorization: Bearer $TOKEN_A" -H "Content-Type: application/json" \
 -d '{"sku":"abc","qty":1,"role":"admin"}' https://api.example.com/v1/orders | grep -E "^(400|422)$"

Observability for security

Log decisions, not secrets. Add correlation for traceability.

Log shape (JSON)

JSON
{
  "ts":"2025-09-05T08:00:00Z",
  "corr":"c-92ab",
  "route":"POST /v1/orders",
  "principal":"sub:u-17",
  "tenant":"t-3",
  "decision":"allow",
  "reason":"scope:orders.write",
  "latency_ms":41,
  "pii_masked":true
}

Detections to wire

  • 403 spikes by route and principal.
  • Sequential object ID access.
  • Tokens reused across services in short windows.
  • Schema-violation bursts on a route or version.
  • Repeated webhook IDs or timestamp skew.

Secrets management and tokens

  • Do not commit secrets. Use your platform’s secret manager.
  • Short TTL for access tokens.
  • Verify iss, aud, exp on every call.
  • Prefer JTI or similar to detect reuse.
  • Rotate on incident and on a regular cadence.

Error handling that is safe

  • Return minimal messages and standard codes.
  • Put details in logs with masking.
  • Never echo raw queries, tokens, or secrets.
  • Use correlation IDs in responses for support.

API discovery and API-BOM

Track and publish a simple CSV or JSON, then render as a dashboard.

PGSQL
service, path, method, version, owner, data_class, auth, pii_fields, last_seen, risk
orders, /v1/orders, POST, 1, @team-orders, sensitive, oauth2, sku|qty, 2025-09-03, high
accounts, /v1/accounts/{id}, GET, 1, @team-identity, sensitive, oauth2, email, 2025-09-04, high

A 90 days plan for developers

30 days

  • Contracts for top services, JWT iss/aud checks, write-route limits + normalization, KPI baseline.

60 days

  • Ownership checks on account, payment, password flows.
  • Persisted GraphQL queries with depth and cost caps.
  • CI neg tests for IDOR, overposting, wrong/expired tokens.

90 days

  • Remove dead routes and versions, enforce deprecation calendar.
  • Auth and schema error budgets with rollback rules.
  • Update the golden service template.

PR checklist (drop into your repo)

  • Contract updated and linted.
  • Token checks present and tested.
  • Ownership check on each sensitive read/write.
  • Strict request schemas, no extra fields allowed.
  • Limits and normalization configured on writes.
  • Webhook signature, timestamp, idempotency where applicable.
  • Logs mask PII and include correlation IDs.
  • Negative tests added or updated.
  • Evidence updated, contract and test results attached.

Introduction to Levo, how it helps developers

Levo adds privacy-preserving runtime visibility and contract validation without moving payloads outside your boundary. Findings become policies and CI tests you can adopt service by service. Pricing stays predictable as services and environments grow, so the secure path remains the easy path for developers.

See how this looks in practice, book a short working session on your two highest risk flows book a demo.

Conclusion

Contracts, portable policies, and small reliable tests make reliability a pipeline property, not a late-night firefight. Ship these guardrails with every service and you will move faster with fewer incidents and cleaner audits.

Related: Learn how Levo is solving the API security issue with it's fix first approach and a product which is scale agnostic, data privacy first and growth immune pricing Levo's API Solution.

FAQs

What is the fastest way to stop BOLA?
Add a shared ownership helper and call it on every sensitive read and write. Add two negative tests per route.

Edge or service for authorization?
Both. Edge for coarse checks and token validity. Service for object ownership and business rules. Keep both as code.

Will rate limits break real users?
Use soft limits for reads, stricter limits for writes. Scope by principal and route. Monitor 429s and tune weekly.

How do I make GraphQL safe without killing flexibility?
Persisted queries only, depth and cost caps, field-level authorization. Disable raw posts in production.

How do I secure gRPC quickly?
mTLS, method allowlists, deadlines, max message size. Add method-level RBAC at the proxy and propagate principal context.

What should I log for investigations?
Correlation ID, route, principal, decision, reason, latency. Mask or tokenize sensitive fields. Short retention for debug logs.

How do I detect IDOR early?
Alert on 403 spikes and sequential ID access. Pair with schema-violation alerts on the same route.

How do I secure webhooks?
HMAC signature with timestamp, five-minute replay window, idempotency keys, last-seen event store per sender.

How do I retrofit a legacy service?
Wrap at the gateway with token validation, schema checks, and limits. Add ownership checks in code next sprint. Put old versions on the deprecation calendar.

How do I avoid flaky CI gates?
Keep tests small with realistic fixtures. Assert on exact contract violations. Run new rules in monitor for a sprint before blocking.

Do I need to validate responses, not just requests?
Yes, response schemas prevent PII leaks and error over-sharing. Start on high-risk routes.

Where do I keep proofs for audits and customers?
Alongside policy code in the repo. Store contract files, test results, configs, and dashboards as artifacts. Automate a weekly export.

What is request normalization in practice?
Canonical field order, de-dup params, trim, lowercase where safe, bound lists and strings. Prevents near-duplicate floods and improves cache hit rates.

Should I rely on third-party SDKs for auth?
Treat them as helpers. Always validate tokens yourself and add ownership checks in your code.

Any tips for partner sandboxes?
Same policies as production with lower thresholds. Seed realistic data. Rotate credentials often. Publish a replay policy.

ON THIS PAGE

We didn’t join the API Security Bandwagon. We pioneered it!