REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.
What you'll learn
- ✓How API key authentication works
- ✓How to issue and verify JWTs
- ✓OAuth 2.0 authorization code flow
- ✓Choosing the right auth method for your API
Prerequisites
- •Basic REST API concepts
- •HTTP headers and status codes
Why Authentication Matters
Every API that handles user data or performs actions on behalf of users needs authentication. Without it, anyone can read private data, modify resources, or abuse your endpoints. Authentication answers one question: who is making this request?
Authorization is the follow-up question: is this caller allowed to do what they are asking? This guide focuses on authentication, but the two go hand in hand.
API Key Authentication
API keys are the simplest form of authentication. The server generates a unique string and gives it to the client. The client includes it in every request.
How It Works
The client sends the key in a header or query parameter. The server looks up the key in a database, finds the associated account, and processes the request.
# API key in a custom header
curl -H "X-API-Key: sk_live_abc123def456" \
https://api.example.com/v1/users
# API key as a query parameter (less secure)
curl "https://api.example.com/v1/users?api_key=sk_live_abc123def456"
Server-Side Validation
Here is a simple Express middleware that validates API keys:
const apiKeys = new Map(); // In production, use a database
function authenticateApiKey(req, res, next) {
const key = req.headers['x-api-key'];
if (!key) {
return res.status(401).json({ error: 'Missing API key' });
}
const account = apiKeys.get(key);
if (!account) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.account = account;
next();
}
app.get('/v1/users', authenticateApiKey, (req, res) => {
// req.account is now available
res.json({ users: [] });
});
When to Use API Keys
API keys work well for server-to-server communication where you trust the client to keep the key safe. They are a poor choice for browser-based apps because the key is visible in the source code.
Strengths: Simple to implement, easy for developers to use, no expiration complexity.
Weaknesses: No built-in expiration, difficult to scope permissions, leaked keys grant full access until revoked.
Security Tips for API Keys
- Always transmit keys over HTTPS.
- Use a prefix like
sk_live_orpk_test_so keys are easy to identify and rotate. - Hash keys before storing them in your database, just like passwords.
- Implement rate limiting per key.
- Support key rotation by allowing multiple active keys per account.
JWT Authentication
JSON Web Tokens (JWTs) are self-contained tokens that encode claims about the user. The server signs the token, and the client sends it with every request. The server verifies the signature without hitting a database.
Token Structure
A JWT has three parts separated by dots: header, payload, and signature.
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJleHAiOjE3MjAwMDAwMDB9.signature
The header declares the algorithm. The payload contains claims like user ID and expiration. The signature proves the token was not tampered with.
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload
{
"userId": 42,
"email": "dev@example.com",
"role": "admin",
"iat": 1719900000,
"exp": 1720000000
}
Issuing Tokens
When the user logs in with valid credentials, the server creates and signs a JWT:
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // Use a strong, random secret
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
SECRET,
{ expiresIn: '7d' }
);
res.json({ accessToken, refreshToken });
});
Verifying Tokens
A middleware that extracts and verifies the JWT from the Authorization header:
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, SECRET);
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
The client sends the token like this:
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
https://api.example.com/v1/users
Refresh Token Flow
Short-lived access tokens limit the damage from a leaked token. Refresh tokens let the client get a new access token without asking the user to log in again.
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, SECRET);
if (payload.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid token type' });
}
// Check if refresh token has been revoked
const isRevoked = await db.revokedTokens.exists(refreshToken);
if (isRevoked) {
return res.status(401).json({ error: 'Token revoked' });
}
const newAccessToken = jwt.sign(
{ userId: payload.userId, email: payload.email, role: payload.role },
SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
When to Use JWTs
JWTs are ideal when you need stateless authentication across multiple services. A microservice can verify the token without calling an auth service. They work well for SPAs and mobile apps.
Strengths: Stateless, scalable, carry user claims, work across services.
Weaknesses: Cannot be revoked individually without a blocklist, payload is readable by anyone (not encrypted by default), token size grows with claims.
OAuth 2.0
OAuth 2.0 is an authorization framework that lets users grant third-party apps limited access to their resources without sharing passwords. When you click “Sign in with Google” or “Connect your GitHub account,” that is OAuth.
Key Roles
- Resource Owner: The user who owns the data.
- Client: The application requesting access.
- Authorization Server: Issues tokens (Google, GitHub, your own server).
- Resource Server: The API that holds the protected data.
Authorization Code Flow
This is the most secure flow for server-side applications.
1. Client redirects user to authorization server
2. User logs in and grants permission
3. Authorization server redirects back with a code
4. Client exchanges code for tokens (server-to-server)
5. Client uses access token to call API
Step-by-Step Implementation
Step 1: Redirect the user to the 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 the callback and exchange the code for tokens.
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we sent
if (!verifyState(state)) {
return res.status(403).json({ error: 'Invalid state' });
}
// Exchange code for access token
const tokenResponse = await fetch(
'https://github.com/login/oauth/access_token',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://myapp.com/auth/callback',
}),
}
);
const { access_token } = await tokenResponse.json();
// Use the token to fetch user info
const userResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` },
});
const githubUser = await userResponse.json();
// Create or update user in your database
const user = await db.users.upsert({
githubId: githubUser.id,
email: githubUser.email,
name: githubUser.name,
});
// Issue your own session or JWT
const sessionToken = jwt.sign({ userId: user.id }, SECRET, {
expiresIn: '24h',
});
res.cookie('session', sessionToken, { httpOnly: true, secure: true });
res.redirect('/dashboard');
});
When to Use OAuth 2.0
Use OAuth when you need to access third-party APIs on behalf of a user, or when you want to let users sign in with existing accounts. It is overkill for simple API-to-API communication.
Strengths: Users never share passwords with third-party apps, granular scopes, widely supported, battle-tested standard.
Weaknesses: Complex to implement from scratch, many moving parts, requires HTTPS everywhere.
Choosing the Right Method
| Factor | API Keys | JWT | OAuth 2.0 |
|---|---|---|---|
| Complexity | Low | Medium | High |
| Best for | Server-to-server | SPAs, mobile apps | Third-party access |
| Stateless | No | Yes | Depends |
| User context | No | Yes | Yes |
| Revocation | Easy (delete key) | Hard (need blocklist) | Easy (revoke token) |
| Expiration | Manual | Built-in | Built-in |
For most APIs, a combination works best. Use API keys for external developer access, JWTs for your own frontend, and OAuth when integrating with third-party services.
Common Security Mistakes
Storing tokens in localStorage. Use httpOnly cookies instead. localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS attacks.
Not validating the JWT algorithm. Always specify the expected algorithm when verifying. Some libraries accept none as an algorithm, which skips signature verification entirely.
// Bad: accepts any algorithm
jwt.verify(token, SECRET);
// Good: explicitly require HS256
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
Putting sensitive data in JWT payloads. The payload is Base64-encoded, not encrypted. Anyone can decode it. Never include passwords, SSNs, or other secrets.
Skipping the state parameter in OAuth. Without it, your OAuth flow is vulnerable to CSRF attacks. Always generate a random state, store it in the session, and verify it in the callback.
Wrapping Up
API keys, JWTs, and OAuth 2.0 each solve different authentication problems. API keys are simple and work for trusted server-to-server calls. JWTs provide stateless, self-contained authentication for modern apps. OAuth 2.0 enables secure third-party access without password sharing. Most production APIs use a combination of these methods. Start with the simplest approach that meets your security requirements, and layer on complexity only when needed.
Related articles
- Web Web Authentication with OAuth 2.0 and OpenID Connect
Implement secure authentication using OAuth 2.0 and OIDC: authorization code flow with PKCE, ID tokens, scopes, token management, and common security pitfalls.
- REST APIs REST API Caching: ETags, Cache-Control, and CDN Patterns
Master HTTP caching for REST APIs. Learn ETags, Cache-Control headers, conditional requests, and CDN integration patterns with practical examples.
- REST APIs REST API Testing: Postman, curl, and Automated Tests
Learn to test REST APIs manually with Postman and curl, then automate with Jest and Supertest. Covers status codes, response validation, and CI integration.
- REST APIs REST vs GraphQL vs gRPC: API Styles Compared
Compare REST, GraphQL, and gRPC for API design. Understand tradeoffs in performance, flexibility, and developer experience to pick the right API style.