Authentication is the first line of defense for your APIs. Whether you’re building a public API, mobile app backend, or microservices, understanding authentication methods is essential. This guide covers the most common approaches, their trade-offs, and when to use each.
Key Takeaways
- 1API keys for server-to-server; OAuth 2.0 for user-facing apps
- 2JWTs are stateless but can’t be revoked—use short expiration + refresh tokens
- 3Always use HTTPS; store secrets in environment variables or secret managers
- 4Sessions work well for web apps; JWTs scale better for APIs and microservices
- 5OAuth 2.0 with PKCE is the secure standard for mobile and SPA authentication
1Authentication vs Authorization
Before diving into methods, understand the difference between authentication (who you are) and authorization (what you can do).
| Concept | Definition | Example |
|---|---|---|
| Authentication | Verify identity | Login with username/password |
| Authorization | Verify permissions | Can this user delete posts? |
Authentication always comes first. You can\
2API Keys
API keys are the simplest authentication method. They're just long, random strings that identify the caller. Simple to implement but limited in security.
// Client: Sending API key in header
const response = await fetch('https://api.example.com/data', {
headers: {
'X-API-Key': 'sk_live_abc123xyz789'
}
});
// Server: Validating API key (Node.js/Express)
app.use('/api', (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.clients.findByApiKey(apiKey);
if (!client) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Attach client info to request
req.client = client;
next();
});**Pros:**
- Simple to implement and use
- No expiration handling needed
- Easy to revoke (just delete from database)
- Good for server-to-server communication
**Cons:**
- No user context (key identifies app, not user)
- Static credentials—if leaked, valid until revoked
- Often sent in URLs (logs, referrer headers)
- No built-in rate limiting or scoping
Use API keys for: server-to-server integrations, public APIs with rate limits, simple scripts. Avoid for: user-facing apps, mobile apps (keys can be extracted), anything with sensitive data.
3Basic Authentication
HTTP Basic Auth sends credentials (username:password) base64-encoded in the Authorization header. Simple but credentials are sent with every request.
// Client: Sending Basic Auth
const credentials = btoa('username:password'); // Base64 encode
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Basic ${credentials}`
}
});
// Server: Validating Basic Auth (Node.js)
function basicAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="API"');
return res.status(401).json({ error: 'Authentication required' });
}
// Decode credentials
const base64Credentials = authHeader.slice(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString();
const [username, password] = credentials.split(':');
// Verify credentials
const user = await db.users.findByUsername(username);
if (!user || !verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.user = user;
next();
}Base64 is encoding, NOT encryption. Anyone who intercepts the header can decode your password. Always use HTTPS with Basic Auth. Even then, avoid for user-facing apps.
Use Basic Auth for: quick prototypes, internal tools, webhook endpoints with shared secrets. It's common in CI/CD pipelines and simple scripts.
4JWT (JSON Web Tokens)
JWTs are self-contained tokens that include encoded claims (user ID, roles, expiration). The server signs them cryptographically, so they can't be tampered with.
JWT Structure: header.payload.signature
Header (algorithm & type):
{
"alg": "HS256",
"typ": "JWT"
}
Payload (claims):
{
"sub": "user123",
"name": "John Doe",
"role": "admin",
"iat": 1706140800,
"exp": 1706144400
}
Signature:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)// Server: Creating a JWT (using jsonwebtoken library)
import jwt from 'jsonwebtoken';
function generateToken(user) {
const payload = {
sub: user.id,
email: user.email,
role: user.role
};
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '1h',
issuer: 'your-app',
audience: 'your-api'
});
}
// Server: Verifying a JWT
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token required' });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'your-app',
audience: 'your-api'
});
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Client: Using the token
const response = await fetch('https://api.example.com/profile', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});| Claim | Description |
|---|---|
| sub (subject) | Who the token is about (user ID) |
| iat (issued at) | When token was created |
| exp (expiration) | When token expires |
| iss (issuer) | Who issued the token |
| aud (audience) | Who the token is for |
JWTs can't be revoked before expiration (they're stateless). For logout/revocation, use short expiration + refresh tokens, or maintain a blacklist of revoked token IDs.
5OAuth 2.0
OAuth 2.0 is an authorization framework that lets users grant limited access to their data without sharing passwords. It's how "Login with Google/GitHub" works.
**OAuth 2.0 Grant Types:**
- 1Authorization Code: Standard web app flow with server-side token exchange
- 2Authorization Code + PKCE: Secure flow for mobile/SPA apps (no client secret)
- 3Client Credentials: Server-to-server, no user involved
- 4Refresh Token: Get new access tokens without re-authentication
Authorization Code Flow:
1. User clicks "Login with GitHub"
→ Redirect to: https://github.com/login/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=read:user
&state=random_string
2. User authorizes your app on GitHub
→ GitHub redirects back: https://yourapp.com/callback
?code=AUTHORIZATION_CODE
&state=random_string
3. Your server exchanges code for tokens
POST https://github.com/login/oauth/access_token
Body: {
client_id, client_secret, code, redirect_uri
}
→ Returns: { access_token, refresh_token, expires_in }
4. Use access token to call GitHub API
GET https://api.github.com/user
Header: Authorization: Bearer ACCESS_TOKEN// Server: OAuth callback handler (Node.js/Express)
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we stored (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state' });
}
// Exchange code for tokens
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: 'https://yourapp.com/auth/github/callback'
})
});
const { access_token, refresh_token } = await tokenResponse.json();
// Get 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
});
// Create session
req.session.userId = user.id;
res.redirect('/dashboard');
});Always validate the state parameter to prevent CSRF attacks. Store it in the session before redirecting to the OAuth provider, then verify it matches on callback.
6Session-Based Authentication
Sessions store authentication state on the server. The client receives a session ID (usually in a cookie) that references server-side data. Traditional but effective.
// Server: Session setup with Express
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
sameSite: 'strict', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Login endpoint
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: { id: user.id, email: user.email } });
});
// Protected endpoint
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = await db.users.findById(req.session.userId);
res.json({ user });
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});| Sessions | JWTs |
|---|---|
| Server stores state | Stateless (self-contained) |
| Easy to revoke (delete session) | Hard to revoke before expiry |
| Scales with session store (Redis) | Scales easily (no shared state) |
| Works with cookies (browser) | Works anywhere (header) |
| CSRF protection needed | No CSRF (not in cookies) |
7Security Best Practices
Regardless of which method you choose, follow these security practices.
**Essential Security Measures:**
- Always use HTTPS—never send credentials over plain HTTP
- Store secrets securely (env vars, secret managers, never in code)
- Use strong, unique secrets (32+ random bytes)
- Implement rate limiting to prevent brute force attacks
- Log authentication attempts (successes and failures)
- Use secure password hashing (bcrypt, argon2) with high work factor
- Validate and sanitize all inputs
- Set appropriate token/session expiration times
// Rate limiting example (express-rate-limit)
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts. Try again later.' },
standardHeaders: true,
legacyHeaders: false
});
app.post('/login', loginLimiter, async (req, res) => {
// ... login logic
});
// Password hashing with bcrypt
import bcrypt from 'bcrypt';
async function hashPassword(password) {
const saltRounds = 12; // Higher = slower but more secure
return await bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}Never log passwords, tokens, or other sensitive data. Never send credentials in URLs (they end up in logs and browser history). Always compare passwords using timing-safe functions.
8Choosing the Right Method
Each authentication method suits different scenarios. Here's a decision guide.
| Scenario | Recommended Method |
|---|---|
| Web app with server rendering | Sessions + cookies |
| Single-page app (SPA) | JWT with refresh tokens |
| Mobile app | OAuth 2.0 + PKCE or JWT |
| Third-party integrations | API keys |
| Social login (Google, GitHub) | OAuth 2.0 |
| Microservices (internal) | JWTs or mTLS |
| Public API | API keys + OAuth 2.0 |
| Webhooks | HMAC signatures |
Many apps combine methods: OAuth for social login → create session/JWT. API keys for rate limiting → JWT for user context. Choose based on your specific needs.
Boost Your Developer Workflow
Free online tools for encoding, formatting, hashing, and more.
Explore Dev ToolsFrequently Asked Questions
What is the difference between authentication and authorization?
Authentication verifies identity (who you are). Authorization determines permissions (what you can do). Authentication always comes first—you can’t authorize an unknown user. JWTs often combine both by including role/permission claims in the token.
Are JWTs secure?
JWTs are secure when used correctly: sign with strong secrets (HS256) or key pairs (RS256), validate all claims (exp, iss, aud), use short expiration with refresh tokens, never store sensitive data in the payload (it’s just base64, not encrypted), and always send over HTTPS.
Should I use sessions or JWTs?
Sessions are simpler for traditional web apps and easy to revoke. JWTs work better for APIs, mobile apps, and microservices (stateless, scale easily). Many apps use both: session cookies for web, JWTs for API. Choose based on your architecture needs.
What is OAuth 2.0 vs OpenID Connect?
OAuth 2.0 is for authorization (granting access to resources). OpenID Connect (OIDC) is an identity layer built on OAuth 2.0 for authentication (verifying identity). When you ’Login with Google’, you’re using OIDC. When you grant an app access to your Google Drive, that’s OAuth 2.0.
How do I handle token refresh?
Use short-lived access tokens (15 min to 1 hour) with long-lived refresh tokens (days to weeks). When access token expires, use the refresh token to get a new one. Store refresh tokens securely (httpOnly cookies or secure storage). Rotate refresh tokens on each use.