jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / API-beveiliging

API-beveiliging

API-beveiliging

API-Regels Die Niet Lekken

Webrisico is zelden mysterieus. Het zit meestal in voorspelbare fouten die onder tijdsdruk blijven staan.

Bij API-beveiliging werkt beveiliging pas echt als autorisatie expliciet per object en actie wordt afgedwongen.

Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.

Directe maatregelen (15 minuten)

Waarom dit telt

De kern van API-beveiliging is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.

Authenticatie

API keys vs OAuth 2.0 vs JWT

Methode Geschikt voor Risico’s Aanbeveling
API key (header/query) Server-to-server, interne services Geen expiry, vaak hardcoded, moeilijk te roteren Alleen voor machine-to-machine met IP-restricties
OAuth 2.0 Bearer token User-facing API’s, third-party integraties Token theft via XSS/logs als niet correct opgeslagen Aanbevolen voor gebruikersauthenticatie
JWT (signed) Stateless authenticatie, microservices Geen server-side revocation, te grote tokens, algorithm confusion Gebruik met korte exp, refresh tokens, en alg validatie
mTLS (client certificates) Zero-trust, service mesh Complex certificaatbeheer Sterkste optie voor service-to-service

JWT veilig implementeren

# Python — PyJWT
import jwt
from datetime import datetime, timedelta, timezone

SECRET_KEY = os.environ["JWT_SECRET"]  # Minimaal 256 bits
ALGORITHM = "HS256"

def create_token(user_id: int, role: str) -> str:
    payload = {
        "sub": str(user_id),
        "role": role,
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        "iss": "api.example.com",
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def verify_token(token: str) -> dict:
    try:
        # Forceer algorithm — voorkom algorithm confusion
        return jwt.decode(
            token,
            SECRET_KEY,
            algorithms=[ALGORITHM],  # NOOIT algorithms=None
            issuer="api.example.com",
            options={"require": ["exp", "sub", "iss"]},
        )
    except jwt.ExpiredSignatureError:
        raise AuthError("Token verlopen")
    except jwt.InvalidTokenError:
        raise AuthError("Ongeldig token")
// Node.js — jsonwebtoken
const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET;

function verifyToken(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Token ontbreekt' });
  }

  try {
    // Forceer algorithm — NOOIT algorithms weglaten
    const decoded = jwt.verify(header.slice(7), SECRET, {
      algorithms: ['HS256'],
      issuer: 'api.example.com',
    });
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Ongeldig token' });
  }
}

Waarschuwing: Gebruik none nooit als toegestaan algorithm. De beruchte alg: none aanval omzeilt de handtekening volledig. Forceer altijd een expliciete lijst van toegestane algoritmen.

Autorisatie

Authenticatie beantwoordt “wie ben je?” Autorisatie beantwoordt “wat mag je?” De drie niveaus van API-autorisatie:

Object-level (BOLA/IDOR)

Broken Object Level Authorization (BOLA), ook bekend als IDOR, is de #1 API-kwetsbaarheid in de OWASP API Security Top 10.

# FOUT — geen autorisatiecheck op objectniveau
@app.get('/api/orders/<int:order_id>')
def get_order(order_id):
    order = Order.query.get_or_404(order_id)
    return jsonify(order.to_dict())  # Elke gebruiker kan elke order opvragen

# GOED — controleer eigenaarschap
@app.get('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.get_or_404(order_id)
    if order.user_id != current_user.id:
        abort(403)
    return jsonify(order.to_dict())

Function-level

# Decorator voor rolgebaseerde toegang
from functools import wraps

def require_role(*roles):
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            if current_user.role not in roles:
                abort(403)
            return f(*args, **kwargs)
        return wrapper
    return decorator

@app.delete('/api/users/<int:user_id>')
@login_required
@require_role('admin')
def delete_user(user_id):
    # Alleen admins kunnen gebruikers verwijderen
    ...

Field-level

# Beperk welke velden zichtbaar zijn per rol
def serialize_user(user, viewer_role):
    base = {
        "id": user.id,
        "name": user.name,
        "email": user.email,
    }
    if viewer_role == "admin":
        base["role"] = user.role
        base["last_login_ip"] = user.last_login_ip
        base["created_at"] = user.created_at.isoformat()
    return base

Regel: Controleer autorisatie op elk niveau. Een gebruiker die een endpoint mag aanroepen, mag niet automatisch elk object binnen dat endpoint benaderen.

Rate limiting & throttling

Zonder rate limiting is je API kwetsbaar voor brute-force aanvallen, credential stuffing, en denial-of-service.

Flask-Limiter

from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

app = Flask(__name__)
limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"],
    storage_uri="redis://localhost:6379",  # Gebruik Redis in productie
)

# Globale limiet geldt voor alle routes

# Strengere limiet op authenticatie-endpoints
@app.post('/api/auth/login')
@limiter.limit("5 per minute")
def login():
    ...

# Hogere limiet voor geauthenticeerde lees-endpoints
@app.get('/api/products')
@limiter.limit("100 per minute")
def list_products():
    ...

# Geen limiet voor health checks
@app.get('/health')
@limiter.exempt
def health():
    return {"status": "ok"}

express-rate-limit (Node.js)

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

// Globale limiet
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minuten
  max: 100,                   // Max 100 requests per window
  standardHeaders: true,      // RateLimit-* headers
  legacyHeaders: false,
  message: { error: 'Te veel verzoeken, probeer later opnieuw' },
  store: new RedisStore({ /* Redis config */ }),
});

// Strikte limiet op login
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Te veel inlogpogingen' },
});

app.use('/api/', globalLimiter);
app.post('/api/auth/login', loginLimiter, authController.login);

Rate limit headers

Stuur altijd rate limit informatie mee in de response:

Header Doel
RateLimit-Limit Maximaal aantal requests in window
RateLimit-Remaining Resterende requests in huidig window
RateLimit-Reset Unix timestamp wanneer window reset
Retry-After Seconden tot client opnieuw mag proberen (bij 429)

Invoervalidatie op API-niveau

Schema-validatie

Valideer elk request tegen een schema. Accepteer nooit ongevalideerde JSON.

Pydantic (Python/FastAPI/Flask)

from pydantic import BaseModel, Field, field_validator
from typing import Optional
from enum import Enum

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    critical = "critical"

class CreateFindingRequest(BaseModel):
    title: str = Field(min_length=3, max_length=200)
    description: str = Field(max_length=10000)
    severity: Priority
    cvss_score: Optional[float] = Field(default=None, ge=0.0, le=10.0)
    affected_hosts: list[str] = Field(max_length=100)

    @field_validator('affected_hosts')
    @classmethod
    def validate_hosts(cls, v):
        import re
        for host in v:
            if not re.fullmatch(r'[\w.\-:]+', host):
                raise ValueError(f"Ongeldige hostname: {host}")
        return v

Joi (Node.js/Express)

const Joi = require('joi');

const createFindingSchema = Joi.object({
  title: Joi.string().min(3).max(200).required(),
  description: Joi.string().max(10000).required(),
  severity: Joi.string().valid('low', 'medium', 'high', 'critical').required(),
  cvss_score: Joi.number().min(0).max(10).precision(1).optional(),
  affected_hosts: Joi.array()
    .items(Joi.string().pattern(/^[\w.\-:]+$/))
    .max(100)
    .required(),
});

// Middleware voor validatie
function validate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true,  // Verwijder onbekende velden
    });
    if (error) {
      return res.status(400).json({
        error: 'Validatiefout',
        details: error.details.map(d => d.message),
      });
    }
    req.body = value;
    next();
  };
}

app.post('/api/findings', validate(createFindingSchema), findingsController.create);

Request size limits

# Flask — beperk request body
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024  # 1 MB
// Express — beperk JSON body
app.use(express.json({ limit: '1mb' }));

// Specifiek per route
app.post('/api/upload', express.json({ limit: '10mb' }), uploadHandler);

Content-Type enforcement

from flask import request, abort

@app.before_request
def enforce_content_type():
    if request.method in ('POST', 'PUT', 'PATCH'):
        if not request.content_type or 'application/json' not in request.content_type:
            abort(415, description="Content-Type moet application/json zijn")

GraphQL-specifieke beveiliging

GraphQL geeft clients de vrijheid om precies op te vragen wat ze nodig hebben. Die flexibiliteit is ook het grootste beveiligingsrisico: zonder beperkingen kan een client de hele database leegvragen met een enkele query.

Introspectie uitschakelen in productie

GraphQL introspectie onthult je volledige schema: typen, velden, relaties, mutaties. In productie is dit een routekaart voor aanvallers.

# Strawberry (Python)
import strawberry
from strawberry.extensions import DisableIntrospection

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[DisableIntrospection()],  # Geen __schema queries
)
// Apollo Server (Node.js)
const { ApolloServer } = require('@apollo/server');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

Query depth limiting

Voorkom diep geneste queries die de server overbelasten:

# Aanval: exponentieel geneste query
{
  user(id: 1) {
    friends {
      friends {
        friends {
          friends {
            # ... tot de server crasht
          }
        }
      }
    }
  }
}
// Apollo Server — graphql-depth-limit
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(5)],  // Maximaal 5 niveaus diep
});
# Strawberry — QueryDepthLimiter
from strawberry.extensions import QueryDepthLimiter

schema = strawberry.Schema(
    query=Query,
    extensions=[
        QueryDepthLimiter(max_depth=5),
    ],
)

Query complexity analysis

Wijs kosten toe aan velden en beperk de totale query-kosten:

// Apollo Server — graphql-query-complexity
const {
  getComplexity,
  simpleEstimator,
  fieldExtensionsEstimator,
} = require('graphql-query-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    requestDidStart: () => ({
      didResolveOperation({ request, document }) {
        const complexity = getComplexity({
          schema: server.schema,
          operationName: request.operationName,
          query: document,
          variables: request.variables,
          estimators: [
            fieldExtensionsEstimator(),
            simpleEstimator({ defaultComplexity: 1 }),
          ],
        });
        if (complexity > 1000) {
          throw new Error(
            `Query te complex: ${complexity}. Maximum: 1000.`
          );
        }
      },
    }),
  }],
});

Batching- en aliasing-risico’s beheersen

GraphQL aliasing maakt het mogelijk dezelfde query meerdere keren in een enkel request uit te voeren — een effectieve bypass van rate limiting:

# Brute-force via aliasing — 100 login-pogingen in 1 request
{
  a1: login(username: "admin", password: "password1") { token }
  a2: login(username: "admin", password: "password2") { token }
  a3: login(username: "admin", password: "password3") { token }
  # ... 97 meer
}

Oplossingen:

// Beperk het aantal aliases per query
const { ApolloArmor } = require('@escape.tech/graphql-armor');

const armor = new ApolloArmor({
  maxAliases: { n: 10 },     // Max 10 aliases per query
  maxDirectives: { n: 5 },    // Max 5 directives per query
  maxDepth: { n: 5 },         // Max diepte 5
  costLimit: { maxCost: 1000 },
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  ...armor.protect(),
});

Field-level autorisatie in GraphQL

# Strawberry — permissie per veld
import strawberry
from strawberry.permission import BasePermission
from strawberry.types import Info

class IsAdmin(BasePermission):
    message = "Admin-rechten vereist"

    def has_permission(self, source, info: Info, **kwargs) -> bool:
        return info.context.user.role == "admin"

@strawberry.type
class User:
    id: int
    name: str
    email: str

    @strawberry.field(permission_classes=[IsAdmin])
    def last_login_ip(self) -> str:
        return self._last_login_ip

API-versioning en deprecation

Waarom versioning

Breaking changes in een API zonder versioning breken alle bestaande clients tegelijk. Versioning geeft clients tijd om te migreren.

Strategieen

Strategie Voorbeeld Voordelen Nadelen
URL-based /api/v1/users Eenvoudig, expliciet, cacheable URL-vervuiling, moeilijk te routen
Header-based Accept: application/vnd.api+json;version=2 Schone URL’s Minder zichtbaar, lastiger te testen
Query parameter /api/users?version=2 Eenvoudig toe te voegen Vervuilt caching, niet RESTful

Aanbeveling: URL-based versioning voor publieke API’s (duidelijk en debugbaar), header-based voor interne API’s (schonere contracten).

# Flask — URL-based versioning
from flask import Blueprint

v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')

@v1.get('/users/<int:user_id>')
def get_user_v1(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify({"id": user.id, "name": user.name})

@v2.get('/users/<int:user_id>')
def get_user_v2(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify({
        "id": user.id,
        "name": user.name,
        "email": user.email,
        "created_at": user.created_at.isoformat(),
    })

Deprecation communiceren

Gebruik de Deprecation en Sunset headers:

HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Nov 2025 00:00:00 GMT
Link: </api/v2/users>; rel="successor-version"

Logging en monitoring

Wat loggen

Event Prioriteit Voorbeeld
Authenticatiefouten Hoog Verkeerd wachtwoord, ongeldig token, verlopen JWT
Autorisatiefouten Hoog 403 Forbidden, BOLA-poging
Rate limit hits Medium 429 Too Many Requests
Onverwachte input Medium Schemavalidatiefouten, onbekende velden
Grote responses Medium Response > 1 MB, query retourneert > 1000 records
Admin-acties Hoog Gebruiker aanmaken/verwijderen, rechten wijzigen
Ongebruikelijke patronen Medium Sequentieel ID-enumeration, bulk data-export

Structured logging

import structlog
import time

logger = structlog.get_logger()

@app.before_request
def log_request_start():
    request._start_time = time.monotonic()

@app.after_request
def log_request(response):
    duration = time.monotonic() - getattr(request, '_start_time', 0)
    logger.info(
        "api_request",
        method=request.method,
        path=request.path,
        status=response.status_code,
        duration_ms=round(duration * 1000, 2),
        remote_addr=request.remote_addr,
        user_id=getattr(current_user, 'id', None),
        content_length=request.content_length,
    )
    return response
// Node.js — pino structured logging
const pino = require('pino');
const logger = pino({ level: 'info' });

app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method,
      path: req.path,
      status: res.statusCode,
      duration_ms: Date.now() - start,
      remote_addr: req.ip,
      user_id: req.user?.id,
    }, 'api_request');
  });
  next();
});

Nooit loggen: wachtwoorden, tokens, sessie-ID’s, creditcardnummers, BSN, of andere gevoelige data. Sanitiseer request bodies voor logging.

Veelvoorkomende fouten

Fout Risico Oplossing
Verbose error messages in productie Stack traces lekken interne architectuur Generieke foutmeldingen voor clients, details alleen in server logs
Geen rate limiting Brute-force, credential stuffing, DoS Flask-Limiter / express-rate-limit met Redis backend
Wildcard CORS (Access-Control-Allow-Origin: *) Elke website kan je API aanroepen Expliciete allowlist van origins
Geen invoervalidatie Injection, business logic fouten, data corruption Pydantic / Joi / JSON Schema op elk endpoint
Excessive data exposure Gevoelige velden (wachtwoord-hash, interne ID’s) in response Expliciete serializers per endpoint en per rol
Broken authentication Token niet gevalideerd, alg: none geaccepteerd Forceer algorithm, valideer exp/iss/sub claims
Geen object-level autorisatie (BOLA) Gebruiker A bekijkt data van gebruiker B via ID-manipulatie Eigenaarschapscheck op elk object-endpoint
Mass assignment Client stuurt role: admin mee in JSON body Allowlist van schrijfbare velden, negeer onbekende velden
GraphQL introspectie in productie Volledig schema zichtbaar voor aanvallers introspection: false in productie
Geen TLS op interne API’s Verkeer onderschepbaar op intern netwerk TLS overal, ook intern (zero-trust)

Checklist

Maatregel Beschrijving Prioriteit
Authenticatie op elk endpoint Geen anonieme toegang tenzij expliciet bedoeld Kritiek
Object-level autorisatie Eigenaarschapscheck bij elke data-operatie Kritiek
Invoervalidatie met schema Pydantic, Joi, of JSON Schema op elke route Kritiek
Rate limiting Per IP en per gebruiker, strenger op auth-endpoints Kritiek
Content-Type enforcement Weiger requests zonder correct Content-Type header Hoog
Request size limits MAX_CONTENT_LENGTH of express.json({ limit }) Hoog
Expliciete CORS-origins Geen wildcards, alleen bekende domeinen Hoog
JWT algorithm forcering Expliciete algorithms=[...], nooit none Kritiek
GraphQL depth/complexity limits Maximale diepte en query-kosten instellen Hoog
Introspectie uit in productie Schema niet zichtbaar voor buitenstaanders Hoog
Structured logging Alle auth-events, rate limits, en fouten loggen Hoog
API-versioning URL-based of header-based, deprecation headers Medium
Korte token-levensduur JWT exp maximaal 15 minuten, refresh via apart token Hoog
Geen gevoelige data in responses Expliciete serializers, field-level filtering per rol Hoog
Foutmeldingen generiek houden Geen stack traces, geen SQL-foutmeldingen naar clients Hoog

Iedereen bouwt API’s tegenwoordig. API-first, microservices, serverless, headless CMS, BFF-pattern — de architectuurdiagrammen zijn prachtig. Honderd services, duizend endpoints, tienduizend pijltjes in een Miro-board dat zo complex is dat je er een PhD in grafentheorie voor nodig hebt om te begrijpen welke service met welke praat.

En dan vraag je: “Hoe is de authenticatie geregeld tussen service A en service B?” Stilte. Een lange, ongemakkelijke stilte. Gevolgd door: “Dat is een intern endpoint, dat hoeft niet.” Ah ja. Intern. Achter de firewall. In hetzelfde Kubernetes-cluster. Waar nooit iets fout kan gaan. Waar nooit een container gecompromitteerd wordt. Waar laterale beweging een mythe is die alleen in security-trainingen voorkomt.

Het patroon is altijd hetzelfde. Eerst bouwen ze de API. Dan bouwen ze het frontend. Dan lanceren ze. Dan, als er tijd over is (er is nooit tijd over), kijken ze naar beveiliging. Rate limiting? “Staat op de backlog.” Input validatie? “Het frontend valideert al.” Object-level autorisatie? “We vertrouwen onze gebruikers.” GraphQL introspectie uitschakelen? “O, kan dat?”

Mijn favoriete ontdekking blijft de GraphQL-API van een fintech-startup die volledige introspectie aan had staan, geen rate limiting had, geen query depth limit, en een user type met het veld passwordHash. Query complexity: nul. Beveiligingsbudget: ook nul. Maar ze hadden wel een fantastische onboarding-flow en een Series A van acht miljoen.

Samenvatting

Beveilig API’s op elke laag: authenticeer elk request met OAuth 2.0 of veilig geconfigureerde JWT’s, autoriseer op object-, functie- en veldniveau, valideer alle invoer tegen een schema (Pydantic, Joi), en beperk misbruik met rate limiting. Voor GraphQL: schakel introspectie uit in productie, beperk query-diepte en complexiteit, en voorkom aliasing-aanvallen. Versioning en structured logging maken je API beheersbaar en auditeerbaar.

In het volgende hoofdstuk behandelen we file uploads: hoe accepteer je bestanden van gebruikers zonder je server open te zetten voor malware, path traversal, en denial-of-service?

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home