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
nonenooit als toegestaan algorithm. De beruchtealg: noneaanval 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 baseRegel: 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 vJoi (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
// 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_ipAPI-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?
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: