API-Sicherheit: Muster für Authentifizierung und Autorisierung
Praxisleitfaden zur Absicherung von APIs mit Token-Auth, rollenbasierter Zugriffskontrolle, Scope-Berechtigungen und Key-Management — plus typische Lücken.

API-Sicherheit ist der Ort, an dem die meisten Sicherheitsvorfälle passieren. Fehlerhafte Authentifizierung steht regelmäßig im OWASP Top 10. Der Unterschied zwischen einer sicheren und einer verwundbaren API liegt nicht in der Wahl des richtigen Frameworks — sondern darin, die Muster zu verstehen, sie korrekt zu implementieren und die typischen Fehler zu kennen.
Authentifizierung beantwortet die Frage „Wer bist du?". Autorisierung beantwortet die Frage „Was darfst du tun?". Es sind getrennte Belange, die getrennt implementiert werden müssen. Wer sie vermischt, erzeugt fragile Systeme, in denen das Hinzufügen einer neuen Rolle Änderungen an der Authentifizierungslogik erfordert.
Tokenbasierte Authentifizierung
JWTs sind der Standard für zustandslose API-Authentifizierung. Der Server stellt ein signiertes Token aus, der Client sendet es bei jeder Anfrage mit, und der Server verifiziert die Signatur, ohne eine Datenbank anzufassen.
import jwt from 'jsonwebtoken';
import { z } from 'zod';
interface TokenPayload {
sub: string; // User ID
roles: string[]; // User roles
scopes: string[]; // Granted permissions
iat: number; // Issued at
exp: number; // Expiration
}
function generateTokens(
user: User,
secret: string,
refreshSecret: string
) {
const payload: Omit<TokenPayload, 'iat' | 'exp'> = {
sub: user.id,
roles: user.roles,
scopes: user.scopes,
};
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m', // Short-lived access token
algorithm: 'HS256',
});
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d', algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
function verifyAccessToken(
token: string,
secret: string
): TokenPayload {
return jwt.verify(token, secret, {
algorithms: ['HS256'], // Explicitly allow only expected algorithms
}) as TokenPayload;
}// ❌ Common JWT mistakes
const badToken = jwt.sign(payload, secret, {
expiresIn: '30d', // Access tokens should be short-lived (15min)
});
// No algorithm restriction on verify — vulnerable to algorithm confusion
const decoded = jwt.verify(token, secret); // Missing algorithms option
// ✅ Secure JWT practices
const goodToken = jwt.sign(payload, secret, {
expiresIn: '15m', // Short-lived access token
algorithm: 'HS256', // Explicit algorithm
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // Only accept expected algorithm
maxAge: '15m', // Reject expired tokens
});Authentifizierungs-Middleware
Die Authentifizierungs-Middleware extrahiert das Token, verifiziert es und hängt den Benutzerkontext an die Anfrage. Sie läuft vor jedem Route-Handler.
import type { Request, Response, NextFunction } from 'express';
interface AuthenticatedRequest extends Request {
user: TokenPayload;
}
function authenticate(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token, secret);
(req as AuthenticatedRequest).user = payload;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
}
// Usage
app.use('/api', authenticate(config.jwtSecret));Rollenbasierte Zugriffskontrolle
RBAC bildet Benutzer auf Rollen und Rollen auf Berechtigungen ab. Es ist das gängigste Autorisierungsmuster, weil es einfach zu verstehen und zu implementieren ist.
// Define roles and their permissions
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [
'users:read', 'users:write', 'users:delete',
'orders:read', 'orders:write', 'orders:delete',
'reports:read', 'settings:write',
],
manager: [
'users:read',
'orders:read', 'orders:write',
'reports:read',
],
viewer: [
'orders:read',
'reports:read',
],
};
function authorize(...requiredPermissions: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as AuthenticatedRequest).user;
// Collect all permissions from the user's roles
const userPermissions = new Set(
user.roles.flatMap((role) => ROLE_PERMISSIONS[role] ?? [])
);
// Check if user has ALL required permissions
const hasPermission = requiredPermissions.every((perm) =>
userPermissions.has(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions,
});
}
next();
};
}
// Usage — route-level authorization
app.get('/api/orders', authorize('orders:read'), getOrders);
app.post('/api/orders', authorize('orders:write'), createOrder);
app.delete('/api/orders/:id', authorize('orders:delete'), deleteOrder);
app.get('/api/reports', authorize('reports:read'), getReports);// ❌ Authorization in route handlers — scattered and error-prone
app.delete('/api/orders/:id', (req, res) => {
if (req.user.role !== 'admin') { // Hardcoded role check
return res.status(403).json({ error: 'Forbidden' });
}
// Every route has its own auth logic
// Easy to forget, easy to get wrong
});
// ✅ Authorization as middleware — centralized and consistent
app.delete(
'/api/orders/:id',
authorize('orders:delete'), // Declarative, reusable
deleteOrder
);
// Permission checks are separate from business logic
// Adding a new role doesn't require modifying route handlersAutorisierung auf Ressourcenebene
RBAC beantwortet „Kann dieser Benutzer auf Bestellungen zugreifen?", aber nicht „Kann dieser Benutzer auf diese konkrete Bestellung zugreifen?". Die Autorisierung auf Ressourcenebene prüft Eigentum und Beziehungen.
async function authorizeResource(
userId: string,
resourceType: string,
resourceId: string,
action: string,
db: Database
): Promise<boolean> {
switch (resourceType) {
case 'order': {
const order = await db.query(
'SELECT customer_id FROM orders WHERE id = $1',
[resourceId]
);
if (order.rows.length === 0) return false;
// Owner can read and update their own orders
if (order.rows[0].customer_id === userId) {
return ['read', 'update'].includes(action);
}
return false;
}
case 'document': {
const access = await db.query(
`SELECT permission FROM document_access
WHERE document_id = $1 AND user_id = $2`,
[resourceId, userId]
);
if (access.rows.length === 0) return false;
const permission = access.rows[0].permission;
if (action === 'read') return true;
if (action === 'write') return permission === 'editor';
return false;
}
default:
return false;
}
}
// Middleware for resource-level auth
function authorizeOwnership(resourceType: string, action: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = (req as AuthenticatedRequest).user;
const resourceId = req.params.id;
const allowed = await authorizeResource(
user.sub,
resourceType,
resourceId,
action,
req.app.locals.db
);
if (!allowed) {
return res.status(403).json({ error: 'Access denied' });
}
next();
};
}
app.get(
'/api/orders/:id',
authorize('orders:read'), // Role check
authorizeOwnership('order', 'read'), // Ownership check
getOrder
);API-Schlüssel-Management
Für die Maschine-zu-Maschine-Kommunikation sind API-Schlüssel einfacher als OAuth-Tokens. Sie erfordern aber sorgfältige Verwaltung — Rotation, Scoping und Rate-Limiting.
import crypto from 'crypto';
// Generate a secure API key
function generateApiKey(): { key: string; hash: string } {
// Generate a cryptographically secure random key
const key = `sk_live_${crypto.randomBytes(32).toString('hex')}`;
// Store only the hash — never store the raw key
const hash = crypto
.createHash('sha256')
.update(key)
.digest('hex');
return { key, hash };
}
// Verify an API key
async function verifyApiKey(
key: string,
db: Database
): Promise<ApiKeyRecord | null> {
const hash = crypto
.createHash('sha256')
.update(key)
.digest('hex');
const result = await db.query(
`SELECT id, owner_id, scopes, expires_at, is_active
FROM api_keys
WHERE key_hash = $1 AND is_active = TRUE`,
[hash]
);
if (result.rows.length === 0) return null;
const record = result.rows[0];
// Check expiration
if (record.expires_at && new Date(record.expires_at) < new Date()) {
return null;
}
// Update last used timestamp
await db.query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
[record.id]
);
return record;
}# ❌ API key anti-patterns
# Storing raw API keys in the database
# Using the same key for all environments
# No expiration date on keys
# No way to revoke a compromised key
# ✅ API key best practices
# Store only SHA-256 hash of the key
# Prefix keys by environment (sk_live_, sk_test_)
# Set expiration dates and rotation schedules
# Track last_used_at for auditing stale keys
# Scope keys to specific permissionsRefresh-Token-Rotation
Access-Tokens laufen aus Sicherheitsgründen schnell ab. Refresh-Tokens erlauben es Clients, neue Access-Tokens zu erhalten, ohne sich erneut zu authentifizieren. Rotiere Refresh-Tokens bei jeder Verwendung, um Diebstahl zu erkennen.
async function refreshAccessToken(
refreshToken: string,
db: Database,
config: AuthConfig
): Promise<{ accessToken: string; refreshToken: string }> {
// Verify the refresh token
const payload = jwt.verify(refreshToken, config.refreshSecret, {
algorithms: ['HS256'],
}) as { sub: string; type: string; jti: string };
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Check if this refresh token has been used before (reuse detection)
const tokenRecord = await db.query(
'SELECT id, used FROM refresh_tokens WHERE jti = $1',
[payload.jti]
);
if (tokenRecord.rows.length === 0) {
throw new Error('Refresh token not found');
}
if (tokenRecord.rows[0].used) {
// Token reuse detected — possible theft
// Invalidate ALL refresh tokens for this user
await db.query(
'DELETE FROM refresh_tokens WHERE user_id = $1',
[payload.sub]
);
throw new Error('Refresh token reuse detected — all sessions revoked');
}
// Mark the current refresh token as used
await db.query(
'UPDATE refresh_tokens SET used = TRUE WHERE jti = $1',
[payload.jti]
);
// Issue new token pair
const user = await db.query(
'SELECT id, roles, scopes FROM users WHERE id = $1',
[payload.sub]
);
return generateTokens(user.rows[0], config.jwtSecret, config.refreshSecret);
}Die wichtigsten Erkenntnisse
- Halte Access-Tokens kurzlebig (15 Minuten) — nutze Refresh-Tokens für die Langlebigkeit; kurzlebige Tokens begrenzen das Kompromittierungsfenster
- Trenne Authentifizierung von Autorisierung — authentifiziere einmal in der Middleware und prüfe Berechtigungen dann pro Route mit deklarativer Middleware
- Speichere Hashes von API-Schlüsseln, nicht die Schlüssel im Klartext — bei einem Datenbankleak erhalten Angreifer nutzlose Hashes statt funktionierender Schlüssel
- Implementiere Autorisierung auf Ressourcenebene — RBAC allein reicht nicht; prüfe, ob Benutzer auf die konkreten Ressourcen zugreifen dürfen, die sie anfordern
- Rotiere Refresh-Tokens bei jeder Verwendung — wenn ein gestohlenes Refresh-Token benutzt wird, deckt der nächste Refresh-Versuch des legitimen Benutzers den Diebstahl auf
- Schränke die Algorithmen zur JWT-Verifizierung ein — übergib immer ein explizites
algorithms-Array, um Algorithm-Confusion-Angriffe zu verhindern


