Expert ReviewedUpdated 2025developer
developer
14 min readApril 7, 2024Updated Oct 2, 2025

API Authentication Guide: Secure Your APIs with Best Practices

Learn API authentication methods including API keys, OAuth 2.0, JWT tokens, and session-based auth. Understand when to use each approach for secure APIs.

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

  • 1
    API keys for server-to-server; OAuth 2.0 for user-facing apps
  • 2
    JWTs are stateless but can’t be revoked—use short expiration + refresh tokens
  • 3
    Always use HTTPS; store secrets in environment variables or secret managers
  • 4
    Sessions work well for web apps; JWTs scale better for APIs and microservices
  • 5
    OAuth 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).
Authentication identifies; authorization permits
ConceptDefinitionExample
AuthenticationVerify identityLogin with username/password
AuthorizationVerify permissionsCan 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}`
  }
});
Standard JWT claims
ClaimDescription
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:**
  1. 1Authorization Code: Standard web app flow with server-side token exchange
  2. 2Authorization Code + PKCE: Secure flow for mobile/SPA apps (no client secret)
  3. 3Client Credentials: Server-to-server, no user involved
  4. 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 vs JWTs comparison
SessionsJWTs
Server stores stateStateless (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 neededNo 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.
Authentication method recommendations
ScenarioRecommended Method
Web app with server renderingSessions + cookies
Single-page app (SPA)JWT with refresh tokens
Mobile appOAuth 2.0 + PKCE or JWT
Third-party integrationsAPI keys
Social login (Google, GitHub)OAuth 2.0
Microservices (internal)JWTs or mTLS
Public APIAPI keys + OAuth 2.0
WebhooksHMAC 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 Tools

Frequently 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.