Categories
JavaScript Security

API Authentication: A Complete Guide to Basic Auth, Bearer Tokens, and JWTs

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:

  1. Take your username and password
  2. Join them with a colon: username:password
  3. Encode the result in Base64
  4. 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:

  1. Validates the credentials
  2. Generates a random token (e.g., a8f5f167f44f4964e6c998dee827110c)
  3. Stores it in the database with user information
  4. 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:

  1. Token blacklist (defeats the stateless advantage)
  2. Short-lived access tokens with refresh token rotation
  3. 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

FeatureBasic AuthBearer (Opaque)JWT
ComplexitySimplestMediumMost Complex
PerformanceN/ADatabase lookupMathematical verification
ScalabilityLimitedRequires shared storageExcellent
RevocationChange passwordDelete from DBRequires infrastructure
StatelessYesNoYes
Best ForInternal toolsStandard appsHigh-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


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.