Foto av Sasun Bughdaryan via Unsplash
API-autentisering med OAuth 2.0: Från teori till produktion
Implementera OAuth 2.0 korrekt i ditt API. Authorization Code Flow med PKCE, token-rotation, scope-design och vanliga säkerhetsfällor att undvika.
OAuth 2.0 i modern API-arkitektur
OAuth 2.0 har blivit de facto-standarden för API-auktorisering, men specifikationen är omfattande och implementationsmisstag skapar allvarliga säkerhetshål. Det räcker inte att förstå flödet på pappret — du behöver förstå varför varje steg existerar och vilka attacker det skyddar mot.
2026 innebär det i praktiken: Authorization Code Flow med PKCE för alla klienttyper (inte bara publika), korta access token-livstider, refresh token-rotation och strikta redirect URI-valideringar. Implicit Flow och Resource Owner Password Flow bör betraktas som deprecated.
Se även vår artikel om API-autentisering för en bredare överblick av autentiseringsmetoder.
Authorization Code Flow med PKCE
PKCE (Proof Key for Code Exchange) skyddar mot authorization code interception-attacker. Klienten genererar ett slumpmässigt code_verifier, hashar det till en code_challenge, och skickar challenge:en vid authorization-begäran. Vid token-utbytet skickas original-verifier:n, och auktoriseringsservern verifierar att de matchar.
Detta är obligatoriskt 2026 — även för konfidentiella klienter med client_secret. OAuth 2.1 (utkastet) kräver PKCE för alla flöden.
// OAuth 2.0 + PKCE implementation
import crypto from "crypto";
import { Router } from "express";
const router = Router();
const AUTH_SERVER = "https://auth.example.com";
const stateStore = new Map<string, {
verifier: string;
returnTo: string;
}>();
// Steg 1: Initiera inloggning
router.get("/auth/login", (req, res) => {
const verifier = crypto
.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
const state = crypto
.randomBytes(16).toString("hex");
stateStore.set(state, {
verifier,
returnTo: req.query.returnTo?.toString() || "/",
});
const url = new URL(
AUTH_SERVER + "/oauth/authorize"
);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id",
process.env.OAUTH_CLIENT_ID!);
url.searchParams.set("redirect_uri",
process.env.OAUTH_REDIRECT_URI!);
url.searchParams.set("scope",
"openid profile email api:read");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set(
"code_challenge_method", "S256");
res.redirect(url.toString());
});
// Steg 2: Hantera callback
router.get("/auth/callback", async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.status(400).json({
error: req.query.error_description || error,
});
}
const stored = stateStore.get(state as string);
if (!stored) {
return res.status(400).json({
error: "Ogiltig state",
});
}
stateStore.delete(state as string);
const tokenRes = await fetch(
AUTH_SERVER + "/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
code,
redirect_uri: process.env.OAUTH_REDIRECT_URI,
code_verifier: stored.verifier,
}),
});
const tokens = await tokenRes.json();
res.cookie("access_token", tokens.access_token, {
httpOnly: true, secure: true,
sameSite: "lax",
maxAge: tokens.expires_in * 1000,
});
res.redirect(stored.returnTo);
});Scope-design för granulär åtkomstkontroll
Scopes definierar vad en access token får göra. Dålig scope-design leder antingen till för breda tokens (säkerhetsrisk) eller för granulära scopes (användbarhetsproblem).
En praktisk tumregel: definiera scopes baserat på resurs + operation. Använd hierarkiska scopes för att förenkla. Till exempel kan 'products:write' implicit inkludera 'products:read'. Dokumentera scope-hierarkin tydligt i din OpenAPI-spec.
Undvik scope-explosion: 10-20 scopes täcker de flesta API:er. Om du har 100+ scopes har du troligen modellerat fel.
// Hierarkisk scope-validering
const SCOPE_HIERARCHY: Record<string, string[]> = {
"admin": [
"products:write", "products:read",
"orders:write", "orders:read",
"users:manage",
],
"products:write": ["products:read"],
"orders:write": ["orders:read"],
};
function expandScopes(scopes: string[]): Set<string> {
const expanded = new Set<string>();
const queue = [...scopes];
while (queue.length > 0) {
const scope = queue.pop()!;
if (expanded.has(scope)) continue;
expanded.add(scope);
const children = SCOPE_HIERARCHY[scope];
if (children) queue.push(...children);
}
return expanded;
}
function requireScope(...required: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const tokenScopes = req.user?.scopes || [];
const expanded = expandScopes(tokenScopes);
const missing = required.filter(
(s) => !expanded.has(s)
);
if (missing.length > 0) {
return res.status(403).json({
type: "/errors/insufficient-scope",
title: "Otillräckliga rättigheter",
status: 403,
detail: "Saknade scopes: " + missing.join(", "),
});
}
next();
};
}
// Användning
router.get("/products",
requireScope("products:read"), listProducts);
router.post("/products",
requireScope("products:write"), createProduct);Refresh token-rotation
Refresh tokens har lång livstid och är högriskvariabler. Token-rotation mitigerar risken: varje gång en refresh token används utfärdas en ny, och den gamla ogiltigförklaras.
Om en gammal refresh token används igen (replay-attack) vet du att den troligtvis stulits. Invalidera hela token-familjen omedelbart — tvinga användaren att autentisera sig på nytt.
Spara aldrig refresh tokens i localStorage eller sessionStorage. Använd httpOnly secure cookies med SameSite-attribut.
// Refresh token rotation med replay-detection
async function refreshTokens(
token: string
): Promise<{ accessToken: string; refreshToken: string }> {
const stored = await db.refreshTokens.findUnique({
where: { tokenHash: hashToken(token) },
});
if (!stored) {
throw new ApiError(401, "Ogiltig refresh token");
}
// Replay-detection
if (stored.usedAt) {
// Komprometterad familj — invalidera allt
await db.refreshTokens.updateMany({
where: { familyId: stored.familyId },
data: { revokedAt: new Date() },
});
throw new ApiError(401,
"Token komprometterad — logga in igen"
);
}
await db.refreshTokens.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Nytt token-par i samma familj
const newRefreshToken = crypto.randomUUID();
await db.refreshTokens.create({
data: {
tokenHash: hashToken(newRefreshToken),
userId: stored.userId,
familyId: stored.familyId,
expiresAt: new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000
),
},
});
return {
accessToken: generateAccessToken(stored.userId),
refreshToken: newRefreshToken,
};
}Vanliga misstag att undvika
Redirect URI-validering: acceptera aldrig dynamiska redirect URI:er. Varje tillåten URI ska vara exakt registrerad — inga wildcards, inga query-parametrar. Öppna redirects är fortfarande en av de vanligaste OAuth-sårbarheterna.
Access token i URL: skicka aldrig tokens som query-parametrar. De hamnar i serverloggar, browser history och Referer-headers. Använd Authorization-headern.
Token-livstid: håll access tokens korta (15-60 minuter). Ett stulet access token med 24 timmars livstid ger angriparen en hel dag. Med 15 minuter begränsas skadan drastiskt.
Client-side token-lagring: localStorage är sårbart för XSS. httpOnly cookies är det säkraste alternativet för webbapplikationer.
För en djupare genomgång av rate limiting som säkerhetskomplement, läs vår guide om rate limiting och API-throttling.