Introduction
You’re building an API. Your frontend needs to authenticate users, and you’re staring at three options: Basic Auth, Bearer Tokens, and JWTs. Pick the wrong one and you’ll either overengineer your simple app or create a security nightmare in production.
This comprehensive guide will show you exactly how each authentication method works, when to use them, and the critical security mistakes that can cost you. Whether you’re building your first API or optimizing an existing system, understanding these fundamentals will help you make informed architectural decisions.
The Problem: HTTP’s Stateless Nature
Before diving into specific authentication methods, let’s establish what problem we’re solving.
Authentication asks: “Who are you?” (Not to be confused with authorization, which asks: “What can you do?”)
Here’s the core challenge: HTTP is stateless.
Think of HTTP like a drive-thru window. You order, they hand you food, and the window closes. Next car pulls up, and they have no idea you just ordered. They don’t remember you, and they don’t try to. Clean slate every time.
That’s by design—it keeps things simple and fast. But it means you have to prove who you are with every single request.
So how do we solve this? That’s where authentication mechanisms come in.
Method 1: Basic Authentication
How It Works
Basic Authentication is the simplest HTTP authentication scheme. Here’s the flow:
- Take your username and password
- Join them with a colon:
username:password - Encode the result in Base64
- Send it in the Authorization header with every request
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
The Critical Misconception: Base64 Is NOT Encryption
Base64 is encoding, not encryption. Think of it like wrapping a gift—the wrapping paper makes it look different, but anyone can unwrap it in 2 seconds.
So why use Base64? Because HTTP headers can only contain certain characters. Base64 converts your username:password into safe characters for transmission. It’s not for security—it’s for compatibility.
Anyone can decode Base64:
// Encoding
const credentials = btoa('username:password');
// Result: dXNlcm5hbWU6cGFzc3dvcmQ=
// Decoding (yes, it's this easy)
const decoded = atob('dXNlcm5hbWU6cGFzc3dvcmQ=');
// Result: username:password
The Security Problem
If you send Basic Auth over plain HTTP, you’re broadcasting your password in unencrypted form across the network. You might as well post your password on Twitter.
Basic Auth over HTTPS is fine—the TLS encryption protects the credentials. But over HTTP? Absolutely not.
There’s another issue: you’re sending credentials with every single request. That’s a lot of opportunities for them to be intercepted or logged somewhere they shouldn’t be. If your credentials end up in server logs, cache layers, or proxy logs, that’s a security incident waiting to happen.
When to Use Basic Auth
Use Basic Authentication for:
- Internal tools
- Local development
- Simple machine-to-machine communication where you control the network
For anything else, there are better options.
Method 2: Bearer Tokens
Understanding the Terminology
Let’s clear up a common misconception right away: Bearer is not the token—it’s the transport mechanism.
Think of “bearer” like an envelope. The word “bearer” on the front tells the post office how to deliver it: “Give this to whoever holds it.” But the envelope itself doesn’t tell you what’s inside—could be a letter, a check, or a gift card. That’s what the token is: the content.
Bearer = delivery method | Token = content
The token that comes after “Bearer” can be any format. Let’s look at how it typically works with opaque tokens.
How Bearer Tokens Work (with Opaque Tokens)
Step 1: Getting a Token
POST /login
Content-Type: application/json
{
"username": "alice",
"password": "securePassword123"
}
The server:
- Validates the credentials
- Generates a random token (e.g.,
a8f5f167f44f4964e6c998dee827110c) - Stores it in the database with user information
- Sends it back to the client
HTTP/1.1 200 OK
Content-Type: application/json
{
"token": "a8f5f167f44f4964e6c998dee827110c",
"expires_in": 3600
}
Step 2: Using the Token
Now, every subsequent request includes the token:
GET /api/user/profile
Authorization: Bearer a8f5f167f44f4964e6c998dee827110c
The server checks its database every single time:
- Is this token valid?
- Which user does it belong to?
- Has it expired?
What Are Opaque Tokens?
An opaque token is called “opaque” because the token itself contains no information. It’s just a random identifier, like a coat check ticket.
The server must query the database on every single request to figure out:
- Who this token belongs to
- Whether it’s still valid
- What permissions the user has
// Simplified server-side validation
async function validateToken(token) {
const session = await database.query(
'SELECT * FROM sessions WHERE token = ? AND expires_at > NOW()',
[token]
);
if (!session) {
throw new Error('Invalid or expired token');
}
return session.user_id;
}Advantages Over Basic Auth
- You’re not sending the password repeatedly
- You can revoke tokens without changing passwords
- You can set expiration times
- You can have multiple tokens per user (web, mobile, etc.)
The Trade-Off
Database lookup on every single request
In high-traffic applications, that’s a real performance consideration. And if you’re running multiple API servers, they all need access to the same token storage. That means you need Redis, a shared database, or some other centralized session store.
This works, but it adds infrastructure complexity.
So the question becomes: What if the token itself could tell us who the user is without hitting the database?
That’s where JWTs come in.
Method 3: JSON Web Tokens (JWTs)
What Makes JWTs Different
A JWT is a self-contained token that includes data right inside it. No database lookup required.
JWT Structure
A JWT has three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breaking this down:
Part 1: Header (eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)
{
"alg": "HS256",
"typ": "JWT"
}
Tells us the algorithm used to sign the token.
Part 2: Payload (eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ)
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
Contains your claims—pieces of information about the user.
Part 3: Signature (SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c)
The server takes the header and payload, combines them, and creates a cryptographic hash using a secret key.
Critical Security Point: JWTs Are NOT Encrypted
The payload is only Base64 encoded, not encrypted. Anyone can decode and read it.
Go to jwt.io right now, paste any JWT, and you’ll see everything inside.
Never put sensitive data in a JWT:
- Passwords
- Social security numbers
- Credit card numbers
- API keys
- Any data you wouldn’t want the client to see
Only put data you’re okay with the client reading:
- User ID
- Username
- Roles/permissions
- Email address
- Expiration time
The Signature: What Makes JWTs Secure
The signature is what makes JWTs tamper-proof (but not private).
If anyone changes even a single character in the payload, the signature won’t match, and the server will reject it:
// Server-side JWT verification
const jwt = require('jsonwebtoken');
function verifyToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded; // Returns the payload if valid
} catch (error) {
throw new Error('Invalid token');
}
}
Key distinction: Tamper-proof ≠ Private
- The signature prevents changes
- But anyone can still read the payload
The Game-Changing Advantage
The server doesn’t need to look up the database on every request.
The server just verifies the signature mathematically. No database hit.
// With opaque tokens: Database query on EVERY request
async function validateOpaqueToken(token) {
return await db.query('SELECT * FROM sessions WHERE token = ?', [token]);
// Network latency: ~5-50ms per request
}
// With JWTs: Mathematical verification only
function validateJWT(token) {
return jwt.verify(token, SECRET_KEY);
// CPU operation: ~0.1-1ms per request
}
JWT verification is typically 5-10x faster, and it means your server can scale horizontally without needing shared session storage. Each server can verify JWTs independently.
The Trade-Off: Revocation
Remember how we could instantly revoke opaque tokens by deleting them from the database?
With JWTs, revocation requires additional infrastructure.
JWTs are stateless—the server doesn’t track them. To revoke a JWT before expiration, you need to implement solutions like:
- Token blacklist (defeats the stateless advantage)
- Short-lived access tokens with refresh token rotation
- Token versioning with user state checks
This is why JWT expiration times are critical. They limit the window of exposure.
The Solution: Access + Refresh Tokens
Most applications use a two-token system:
Access Token (JWT): Short-lived (15 minutes)
{
"sub": "user123",
"exp": 1699999999,
"iat": 1699999099
}
Refresh Token (Opaque): Long-lived (7 days), stored in database
POST /token/refresh
Authorization: Bearer <refresh_token>
Response: { "access_token": "new_jwt..." }
When the access token expires, the client uses the refresh token to get a new one. The refresh token is stored in the database and can be revoked.
This gives you:
- Performance benefits of JWTs
- Revocation control via refresh tokens
Signing Algorithms: HS256 vs RS256
HS256 (Symmetric Key): Think of it like a house key. The same key locks and unlocks the door.
// Same secret for signing and verifying
const token = jwt.sign(payload, SECRET_KEY);
const verified = jwt.verify(token, SECRET_KEY);
✅ Fast, simple, secure
✅ Use when you control everything
❌ All services need the same secret
RS256 (Asymmetric Keys): Think of it like a mailbox. The private key means only you can put mail in (sign tokens). The public key means anyone can check what’s inside (verify tokens).
// Private key signs (auth service only)
const token = jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256' });
// Public key verifies (any service)
const verified = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });
✅ Multiple services can verify without sharing secrets
✅ Better for microservices architectures
❌ Slower than HS256
When to use which:
- HS256: Simpler setups where you control everything
- RS256: Microservices architectures where multiple services need to verify tokens from a central authentication service
Security Best Practices
These are the mistakes I see constantly, and they’re all completely avoidable.
1. Always Use HTTPS
I don’t care which authentication method you choose—Basic, Bearer, or JWT. None of them are secure over plain HTTP.
HTTPS encrypts the entire request, including headers. No exceptions.
# Force HTTPS redirect
server {
listen 80;
return 301 https://$server_name$request_uri;
}
2. Token Storage: Where You Store Tokens Matters
Option 1: Local Storage
localStorage.setItem('token', token);
❌ Vulnerable to XSS (Cross-Site Scripting)
❌ If an attacker injects malicious JavaScript, it can read localStorage
Option 2: HTTP-Only Cookies
// Server sets cookie
res.cookie('token', token, {
httpOnly: true, // Can't be accessed by JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict' // CSRF protection
});
✅ Protected against XSS
⚠️ Vulnerable to CSRF (but sameSite helps)
The Solution: Use HTTP-only cookies with the sameSite attribute set to strict or lax.
3. Set Appropriate Expiration Times
Don’t create a JWT that’s valid for a year. That’s a year-long security window if it gets stolen.
Recommended expiration times:
- Access tokens: 15-60 minutes
- Refresh tokens: 7-30 days
- Remember-me tokens: 30-90 days
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, SECRET, { expiresIn: '7d' });
4. Never Roll Your Own Crypto
Use established libraries for JWTs:
- Node.js:
jsonwebtoken - Python:
PyJWT - Go:
golang-jwt/jwt - Java:
jjwt
These have been tested, audited, and battle-hardened. Your custom implementation probably hasn’t.
5. Protect Against Algorithm Confusion Attacks
There’s a classic vulnerability where attackers change the algorithm from RS256 to none or HS256 to bypass signature verification.
Always explicitly specify the expected algorithm:
// Vulnerable
jwt.verify(token, SECRET);
// Secure
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
While most modern JWT libraries protect against this by default, explicitly whitelisting algorithms is a defense-in-depth measure.
Decision Framework: Which Method Should You Use?
Use Basic Auth When:
- Building internal tools that only your team uses
- Local development environments
- Simple machine-to-machine communication where you control the network
- You want the absolute simplest implementation
Requirements: Must use HTTPS
Use Bearer Tokens (Opaque) When:
- Building a standard web application
- You need easy token revocation
- Your traffic volume is moderate (database lookups are fine)
- You’re running a single server or have simple infrastructure
- You don’t need to scale horizontally immediately
Advantages: Simple, easy to revoke, well-understood
Trade-offs: Database lookup per request, requires shared storage for multiple servers
Use JWTs When:
- You need to scale horizontally with multiple servers
- Performance is critical (high-traffic APIs)
- You want stateless authentication
- You’re building a microservices architecture
- You need different services to verify tokens independently
Advantages: Fast, stateless, scales easily
Trade-offs: Harder to revoke, requires careful expiration management
The Golden Rule
Match the complexity of your auth system to the complexity of your actual requirements.
Don’t use JWTs just because they’re trendy if simple sessions work fine. Don’t overengineer a basic CRUD app with a complex auth system it doesn’t need.
Quick Reference Comparison
| Feature | Basic Auth | Bearer (Opaque) | JWT |
|---|---|---|---|
| Complexity | Simplest | Medium | Most Complex |
| Performance | N/A | Database lookup | Mathematical verification |
| Scalability | Limited | Requires shared storage | Excellent |
| Revocation | Change password | Delete from DB | Requires infrastructure |
| Stateless | Yes | No | Yes |
| Best For | Internal tools | Standard apps | High-scale APIs |
Implementation Examples
Basic Auth (Node.js/Express)
const express = require('express');
const app = express();
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ error: 'No credentials provided' });
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
// Validate against database
if (username === 'admin' && password === 'secret') {
req.user = { username };
next();
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
Bearer Token with Opaque Tokens
const crypto = require('crypto');
// Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Validate credentials
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate token
const token = crypto.randomBytes(32).toString('hex');
// Store in database
await db.query(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
[token, user.id, new Date(Date.now() + 3600000)]
);
res.json({ token });
});
// Protected endpoint
app.use(async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
// Validate token
const session = await db.query(
'SELECT * FROM sessions WHERE token = ? AND expires_at > NOW()',
[token]
);
if (!session) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.user = { id: session.user_id };
next();
});JWT Implementation
const jwt = require('jsonwebtoken');
// Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create access token (short-lived)
const accessToken = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Create refresh token (long-lived, stored in DB)
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token
await db.query(
'INSERT INTO refresh_tokens (token, user_id) VALUES (?, ?)',
[refreshToken, user.id]
);
res.json({ accessToken, refreshToken });
});
// Middleware
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256']
});
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
// Refresh endpoint
app.post('/token/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Check if refresh token exists in database
const tokenExists = await db.query(
'SELECT * FROM refresh_tokens WHERE token = ? AND user_id = ?',
[refreshToken, decoded.sub]
);
if (!tokenExists) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const newAccessToken = jwt.sign(
{ sub: decoded.sub },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Recap
Basic Auth is simple but requires HTTPS and sends credentials repeatedly. Use it for internal tools and development.
The Bearer authorization scheme is how you transport tokens—those tokens can be opaque or JWTs.
Opaque tokens require database lookups but are easy to revoke. Great for standard applications.
JWTs are self-contained, fast to verify, and stateless—perfect for scaling. But they’re harder to revoke without additional infrastructure.
The key is understanding your requirements:
- Need simplicity? Basic Auth or opaque tokens
- Need performance and scale? JWTs
- Need easy revocation? Opaque tokens or JWT + refresh token pattern
Always use HTTPS. Always.
What’s Next?
This guide covered the fundamentals of API authentication. In a follow-up article, we’d explore:
- OAuth 2.0: How “Sign in with Google” actually works
- Different grant types: Authorization Code, Client Credentials, etc.
- PKCE: Security for mobile apps
- OpenID Connect: Authentication layer on top of OAuth
- Single Sign-On (SSO): Enterprise authentication
But these more complex protocols all build on the fundamentals we covered today. Master these basics first, and you’ll understand how everything else fits together.
Additional Resources
- JWT.io – Decode and verify JWTs
- OWASP Authentication Cheat Sheet
- RFC 6750 – Bearer Token Usage
- RFC 7519 – JSON Web Token
Remember: Security is not about using the most complex solution—it’s about using the right solution correctly. Choose wisely, implement carefully, and always prioritize HTTPS.