DevToolBoxGRATIS
Blog

API-authenticatie: OAuth 2.0 vs JWT vs API Key

13 min lezenby DevToolBox

Choosing the right API authentication method is critical for security, scalability, and developer experience. This comprehensive guide compares API Keys, JWT Bearer Tokens, OAuth 2.0, session-based auth, and Basic Auth β€” with code examples, security considerations, and a decision framework to help you pick the best approach for your project.

Why API Authentication Matters

Every API endpoint that handles user data or performs privileged operations must verify the identity of the caller. Without proper authentication, your API is open to data theft, unauthorized modifications, and abuse.

It is important to distinguish between authentication (authn) and authorization (authz). Authentication answers "Who are you?" β€” it verifies the caller's identity. Authorization answers "What can you do?" β€” it determines which resources and operations the authenticated caller is allowed to access. Most real-world systems need both: authenticate the user first, then check their permissions.

Modern APIs typically use one of five authentication strategies: API Keys, JWT Bearer Tokens, OAuth 2.0, session-based cookies, or HTTP Basic Auth. Each comes with different trade-offs in security, complexity, and use cases.

API Keys

An API key is a long, random string assigned to a client application. The client includes this key in every request so the server can identify and authorize the caller. API keys are the simplest form of API authentication.

How API Keys Work

The server generates a unique key per client. The client sends the key in an HTTP header (recommended) or as a query parameter. The server looks up the key in a database, identifies the associated client/project, and applies rate limits and permissions.

Header vs Query Parameter

Sending keys in headers (e.g., X-API-Key or Authorization) is preferred because query parameters appear in server logs, browser history, and referrer headers, increasing the risk of key leakage.

Pros: Simple to implement, easy for developers to use, good for server-to-server communication, easy to rotate and revoke.
Cons: No user context (identifies app, not user), can be leaked in logs if sent as query param, no built-in expiration, anyone with the key has full access.
// Express.js β€” API Key middleware
const express = require('express');
const app = express();

// API key validation middleware
function apiKeyAuth(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  if (!apiKey) {
    return res.status(401).json({ error: 'API key required' });
  }

  // Look up key in database
  const client = await db.apiKeys.findOne({ key: apiKey, active: true });
  if (!client) {
    return res.status(403).json({ error: 'Invalid API key' });
  }

  // Attach client info for downstream use
  req.client = client;
  next();
}

app.get('/api/data', apiKeyAuth, (req, res) => {
  res.json({ data: 'protected resource', client: req.client.name });
});

Bearer Token / JWT Authentication

JSON Web Tokens (JWT) provide stateless authentication by encoding user claims directly into the token. The client sends the token in the Authorization header using the Bearer scheme. The server verifies the token signature without any database lookup.

A JWT consists of three Base64URL-encoded parts separated by dots: Header (algorithm and token type), Payload (user claims like sub, exp, role), and Signature (cryptographic proof of integrity). Because the payload is only encoded and not encrypted, never store sensitive data in it.

Stateless Authentication

Unlike sessions, the server does not need to store any state. The token itself contains all necessary user information. This makes JWT ideal for microservices and horizontally scaled architectures where sharing session stores across instances is impractical.

// Express.js β€” JWT Bearer Token auth
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;

// Login endpoint β€” issue token
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findOne({ email });

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    SECRET,
    { expiresIn: '15m', issuer: 'https://api.example.com' }
  );
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    SECRET,
    { expiresIn: '7d' }
  );

  res.json({ accessToken, refreshToken });
});

// Auth middleware β€” verify Bearer token
function bearerAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Bearer token required' });
  }
  try {
    const token = header.split(' ')[1];
    req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

app.get('/api/profile', bearerAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

OAuth 2.0 Flows

OAuth 2.0 is a delegation framework that lets users grant third-party applications limited access to their resources without sharing credentials. It is the standard behind "Sign in with Google/GitHub" buttons and API integrations.

Authorization Code Flow

The most secure flow for server-side web apps. The user is redirected to the authorization server, logs in, and is redirected back with a short-lived authorization code. The server exchanges this code for tokens via a back-channel request. The access token never touches the browser.

Client Credentials Flow

For machine-to-machine (M2M) communication where no user is involved. The client authenticates directly with the authorization server using its client_id and client_secret to obtain an access token. Common for backend services, cron jobs, and microservice communication.

Authorization Code with PKCE

Designed for public clients like single-page apps (SPAs) and mobile apps that cannot securely store a client_secret. PKCE (Proof Key for Code Exchange) adds a code_verifier/code_challenge pair to prevent authorization code interception attacks. This is now the recommended flow for all browser-based applications.

// Express.js β€” OAuth 2.0 Authorization Code Flow
const axios = require('axios');

// Step 1: Redirect user to authorization server
app.get('/auth/github', (req, res) => {
  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    redirect_uri: 'https://myapp.com/auth/callback',
    scope: 'read:user user:email',
    state: generateRandomState(), // CSRF protection
  });
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

// Step 2: Handle callback β€” exchange code for token
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  if (!verifyState(state)) {
    return res.status(403).json({ error: 'Invalid state' });
  }

  // Exchange authorization code for access token
  const tokenRes = await axios.post(
    'https://github.com/login/oauth/access_token',
    {
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: 'https://myapp.com/auth/callback',
    },
    { headers: { Accept: 'application/json' } }
  );

  const { access_token } = tokenRes.data;

  // Step 3: Use token to fetch user data
  const userRes = await axios.get('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${access_token}` },
  });

  // Create session or issue your own JWT
  const jwt = issueJwt(userRes.data);
  res.cookie('token', jwt, { httpOnly: true, secure: true, sameSite: 'lax' });
  res.redirect('/dashboard');
});

Session-Based Authentication

Session-based auth is the traditional approach used by web applications. After login, the server creates a session object stored in a database or in-memory store (like Redis), and sends the client a session ID in a cookie. On every subsequent request, the browser automatically includes the cookie, and the server looks up the session.

Because cookies are sent automatically, session-based auth is vulnerable to Cross-Site Request Forgery (CSRF). To mitigate this, use CSRF tokens β€” a random value included in forms and verified on the server β€” and set the SameSite cookie attribute.

// Express.js β€” Session-based auth with express-session
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const redisClient = redis.createClient({ url: process.env.REDIS_URL });

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,        // HTTPS only
    httpOnly: true,       // Not accessible via JS
    sameSite: 'lax',     // CSRF protection
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));

// Login
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findOne({ email });

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in' });
});

// Auth middleware
function sessionAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  next();
}

// Logout β€” destroy session
app.post('/auth/logout', (req, res) => {
  req.session.destroy(() => {
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});

HTTP Basic Authentication

Basic Auth sends the username and password in the Authorization header, encoded as Base64. The format is Authorization: Basic base64(username:password). It is the simplest HTTP authentication scheme and is defined in RFC 7617.

Basic Auth is still commonly used for internal APIs, development/staging environments, CI/CD pipelines, and simple webhooks. It should always be used over HTTPS, as Base64 is encoding, not encryption β€” the credentials can be trivially decoded.

// Express.js β€” Basic Auth middleware
function basicAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Basic ')) {
    res.set('WWW-Authenticate', 'Basic realm="API"');
    return res.status(401).json({ error: 'Basic auth required' });
  }

  const base64 = header.split(' ')[1];
  const decoded = Buffer.from(base64, 'base64').toString('utf-8');
  const [username, password] = decoded.split(':');

  // Validate credentials (use timing-safe comparison!)
  const valid = username === process.env.API_USER
    && crypto.timingSafeEqual(
         Buffer.from(password),
         Buffer.from(process.env.API_PASS)
       );

  if (!valid) {
    return res.status(403).json({ error: 'Invalid credentials' });
  }
  req.user = { username };
  next();
}

// Usage
app.get('/api/internal', basicAuth, (req, res) => {
  res.json({ message: 'Authenticated', user: req.user.username });
});

// Client-side request with Basic Auth
// curl -u username:password https://api.example.com/internal
// fetch('https://api.example.com/internal', {
//   headers: { Authorization: 'Basic ' + btoa('user:pass') }
// });

Authentication Methods Comparison

Use this table to quickly compare all five authentication methods across key dimensions:

MethodSecurityComplexityStatelessBest For
API KeyLow-MediumLowYesServer-to-server, public APIs
JWT BearerMedium-HighMediumYesSPAs, mobile apps, microservices
OAuth 2.0HighHighYesThird-party integrations, SSO
Session/CookieMedium-HighMediumNoTraditional web apps
Basic AuthLowLowYesInternal APIs, dev/staging

Token Storage Strategies

Where you store authentication tokens has a direct impact on security. The three main options each have different vulnerability profiles:

httpOnly Cookies (Recommended)

Tokens stored in httpOnly cookies cannot be accessed by JavaScript, making them immune to XSS attacks. The browser automatically sends them with requests. However, cookies are vulnerable to CSRF attacks, so you must use SameSite attributes and CSRF tokens.

localStorage

Easy to implement and works well with SPAs. However, localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS attacks. If an attacker injects malicious script, they can steal the token.

In-Memory (JavaScript Variable)

The most secure option against both XSS and CSRF because the token exists only in a JavaScript closure or variable. The downside is that tokens are lost on page refresh, requiring silent re-authentication.

// httpOnly Cookie approach (server-side)
res.cookie('access_token', token, {
  httpOnly: true,    // JS cannot read this cookie
  secure: true,      // HTTPS only
  sameSite: 'strict', // No cross-site sending
  maxAge: 900000,    // 15 minutes
  path: '/',
});

// localStorage approach (client-side) β€” less secure
localStorage.setItem('access_token', token);

// fetch with localStorage token
fetch('/api/data', {
  headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` }
});

// In-memory approach (client-side) β€” most secure
let accessToken = null; // lives only in closure

async function login(email, password) {
  const res = await fetch('/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
  });
  const data = await res.json();
  accessToken = data.accessToken; // stored only in memory
}

async function fetchProtected(url) {
  return fetch(url, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
}

Token Refresh Strategies

Short-lived access tokens improve security but require a mechanism to obtain new tokens without re-authentication. Here are the main refresh strategies:

Refresh Token Rotation

Each time a refresh token is used, a new refresh token is issued and the old one is invalidated. If a stolen refresh token is used after the legitimate user has already refreshed, the server detects the reuse and invalidates the entire token family, forcing re-authentication.

Sliding Window Expiration

The token expiration is extended with each successful request. If the user is actively using the app, they stay logged in. After a period of inactivity, the token expires. This is common in session-based systems.

Silent Refresh (for SPAs)

The SPA uses a hidden iframe to call the authorization server and obtain a new access token without user interaction. This approach is being replaced by refresh token rotation with PKCE for better security.

// Express.js β€” Refresh Token Rotation
const refreshTokens = new Map(); // In production, use Redis/DB

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }

  try {
    const payload = jwt.verify(refreshToken, SECRET);

    // Check if this refresh token has already been used (rotation)
    const stored = refreshTokens.get(payload.sub);
    if (!stored || stored.token !== refreshToken) {
      // Token reuse detected! Invalidate all tokens for this user
      refreshTokens.delete(payload.sub);
      return res.status(401).json({ error: 'Token reuse detected' });
    }

    // Issue new token pair
    const newAccessToken = jwt.sign(
      { sub: payload.sub, role: payload.role },
      SECRET,
      { expiresIn: '15m' }
    );
    const newRefreshToken = jwt.sign(
      { sub: payload.sub, type: 'refresh' },
      SECRET,
      { expiresIn: '7d' }
    );

    // Rotate: store new refresh token, invalidate old
    refreshTokens.set(payload.sub, {
      token: newRefreshToken,
      expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
    });

    res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Rate Limiting & Throttling

Rate limiting protects your API from abuse and ensures fair usage among clients. It is especially important for public APIs that use API keys, where a single key could otherwise consume all available resources.

Most rate limiters use a sliding window or token bucket algorithm. When a client exceeds the limit, the server returns HTTP 429 Too Many Requests with a Retry-After header indicating when the client can retry.

// Express.js β€” Rate limiting per API key
const rateLimit = require('express-rate-limit');

// Global rate limit
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                   // 100 requests per window
  standardHeaders: true,      // Return rate limit info in headers
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
  keyGenerator: (req) => {
    // Rate limit by API key or IP
    return req.headers['x-api-key'] || req.ip;
  },
});

app.use('/api/', globalLimiter);

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 login attempts per 15 min
  message: { error: 'Too many login attempts' },
});

app.use('/auth/login', authLimiter);

// Custom response headers:
// X-RateLimit-Limit: 100
// X-RateLimit-Remaining: 73
// X-RateLimit-Reset: 1672531200
// Retry-After: 900 (seconds, only on 429)

Security Best Practices

  • Always use HTTPS β€” Authentication tokens sent over HTTP can be intercepted by anyone on the network. Never deploy an API that accepts credentials over plain HTTP.
  • Use short-lived access tokens β€” Set access token expiration to 15-60 minutes. Combine with refresh tokens for longer sessions. A compromised short-lived token limits the window of attack.
  • Restrict scopes and permissions β€” Follow the principle of least privilege. API keys and OAuth tokens should only have the minimum permissions needed. Use granular scopes like read:users instead of broad admin access.
  • Implement key rotation β€” API keys and secrets should be rotated regularly. Support multiple active keys during the rotation period so clients can transition without downtime.
  • Validate and sanitize all input β€” Even with authentication, never trust client input. Validate token formats, check expiration, verify audience and issuer claims.
  • Log and monitor authentication events β€” Track failed login attempts, token refreshes, and unusual access patterns. Set up alerts for brute-force attempts and token reuse.
  • Use timing-safe comparisons β€” When comparing secrets, API keys, or tokens, always use constant-time comparison functions to prevent timing attacks.
  • Never expose secrets in client-side code β€” Client secrets, signing keys, and database credentials must never appear in frontend JavaScript, mobile app bundles, or public repositories.

Try our related security tools

FAQ

When should I use API Keys vs OAuth 2.0?

Use API Keys for simple server-to-server integrations where you need to identify the calling application. Use OAuth 2.0 when you need delegated user authorization β€” i.e., when a third-party app needs to act on behalf of a user. OAuth provides granular scopes and user consent, while API keys are all-or-nothing.

Is JWT better than session-based authentication?

Neither is universally better. JWT is ideal for stateless, distributed systems (microservices, mobile APIs) because no server-side storage is needed. Session-based auth is better when you need easy revocation (just delete the session), smaller payloads, and simpler security model. Many production systems use both: JWT for API auth and sessions for web UI.

How do I secure API keys in a frontend application?

You cannot securely store API keys in frontend code β€” any secret in client-side JavaScript is exposed. Instead, create a backend proxy that holds the API key and forwards requests, use OAuth with PKCE for user-facing auth, or use restricted API keys with domain/IP allowlists and strict rate limits.

What is the difference between authentication and authorization?

Authentication (authn) verifies identity β€” confirming who the caller is (e.g., validating a JWT signature or checking credentials). Authorization (authz) determines permissions β€” what the authenticated caller is allowed to do (e.g., checking if the user has the "admin" role). Authentication always comes first; authorization depends on it.

Should I build my own auth system or use a service?

For most applications, use a proven auth service or library (Auth0, Firebase Auth, Supabase Auth, Passport.js). Rolling your own authentication is error-prone and security-critical. Build custom auth only if you have very specific requirements and a security-experienced team to maintain it.

𝕏 Twitterin LinkedIn
Was dit nuttig?

Blijf op de hoogte

Ontvang wekelijkse dev-tips en nieuwe tools.

Geen spam. Altijd opzegbaar.

Try These Related Tools

JWTJWT DecoderB64Base64 Encoder/Decoder#Hash Generator>>cURL to Code Converter

Related Articles

Hoe JWT werkt: Complete gids voor JSON Web Tokens

Leer hoe JWT-authenticatie werkt met header, payload en handtekening.

REST API Best Practices: De Complete Gids voor 2026

Leer REST API design best practices: naamconventies, foutafhandeling, authenticatie en beveiliging.