Skip to content

Secure API Authentication: JWT, OAuth and Sessions

A practical guide to choosing and implementing secure authentication for modern APIs: JWT tokens, OAuth 2.0 flows and session management pitfalls.

6 min read
Shield icon protecting API endpoints with authentication layers

Why API Authentication Is Still Broken in 2024

Every week a new breach report surfaces. Stolen tokens, session hijacking, insecure OAuth flows—these aren't exotic attacks. They are the bread and butter of modern exploitation. The root cause is almost always the same: developers picking an authentication strategy without understanding the tradeoffs.

Authentication looks simple on the surface. You verify who someone is, hand them a credential, and check it on subsequent requests. But the devil lives in token storage, rotation policies, scope management, and a dozen other details that get glossed over in tutorials.

This guide walks through the three dominant API authentication approaches—JWT, OAuth 2.0, and server-side sessions—and shows you how to implement each one without the common mistakes that lead to breaches.

JWT Authentication: Power and Peril

JSON Web Tokens became the default for stateless authentication. A JWT contains a base64-encoded header, payload, and signature. The server generates it, the client stores it, and every request carries it back.

The appeal is obvious: no server-side session store, horizontal scaling without sticky sessions, and easy cross-service verification. But JWTs carry real risks when implemented carelessly.

tstypescript
// ❌ Bad: Long-lived JWT with sensitive data in payload
import jwt from "jsonwebtoken";
 
function generateToken(user: User): string {
  return jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,
      ssn: user.ssn, // Never put sensitive data in JWT
    },
    "my-secret-key", // Hardcoded secret
    { expiresIn: "30d" } // Way too long
  );
}
tstypescript
// ✅ Good: Short-lived JWT with minimal claims and proper key management
import jwt from "jsonwebtoken";
 
interface TokenPayload {
  sub: string;
  role: string;
  jti: string;
}
 
function generateAccessToken(user: User): string {
  const payload: TokenPayload = {
    sub: user.id,
    role: user.role,
    jti: crypto.randomUUID(),
  };
 
  return jwt.sign(payload, process.env.JWT_SECRET!, {
    algorithm: "HS256",
    expiresIn: "15m",
    issuer: "api.example.com",
    audience: "example.com",
  });
}
 
function generateRefreshToken(user: User): string {
  return jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET!,
    { algorithm: "HS256", expiresIn: "7d" }
  );
}

Short-lived access tokens (15 minutes or less) paired with refresh tokens limit the blast radius of a compromised token. The jti claim gives you a unique identifier for revocation tracking.

Token Storage: Where Most Teams Get It Wrong

Where you store tokens on the client matters more than how you generate them. LocalStorage is accessible to any JavaScript on the page—one XSS vulnerability and your tokens are exfiltrated.

tstypescript
// ❌ Bad: Storing JWT in localStorage
function login(token: string): void {
  localStorage.setItem("access_token", token);
}
 
function getAuthHeader(): Record<string, string> {
  const token = localStorage.getItem("access_token");
  return { Authorization: `Bearer ${token}` };
}
tstypescript
// ✅ Good: HTTP-only cookies with proper flags
import { NextResponse } from "next/server";
 
function setAuthCookies(
  response: NextResponse,
  accessToken: string,
  refreshToken: string
): NextResponse {
  response.cookies.set("access_token", accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 900, // 15 minutes
    path: "/",
  });
 
  response.cookies.set("refresh_token", refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 604800, // 7 days
    path: "/api/auth/refresh",
  });
 
  return response;
}

HTTP-only cookies cannot be read by JavaScript, eliminating the XSS token theft vector. The sameSite: "strict" flag prevents CSRF attacks by blocking the cookie from cross-origin requests. Setting the refresh token path to only the refresh endpoint limits its exposure.

OAuth 2.0 Flows: Picking the Right One

OAuth 2.0 is not authentication—it is authorization. But combined with OpenID Connect, it becomes the foundation for modern identity systems. The problem is OAuth defines multiple flows, and picking the wrong one creates security holes.

tstypescript
// Authorization Code Flow with PKCE (recommended for SPAs and mobile)
import crypto from "crypto";
 
function generatePKCE(): {
  codeVerifier: string;
  codeChallenge: string;
} {
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
 
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  return { codeVerifier, codeChallenge };
}
 
function buildAuthorizationUrl(
  clientId: string,
  redirectUri: string,
  codeChallenge: string
): string {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: "openid profile email",
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    state: crypto.randomBytes(16).toString("hex"),
  });
 
  return `https://auth.example.com/authorize?${params.toString()}`;
}

The Authorization Code flow with PKCE is now the recommended approach for all client types. The implicit flow is deprecated—it exposes tokens in the URL fragment, which ends up in browser history and server logs.

tstypescript
// ❌ Bad: Implicit flow exposes tokens in URL
// redirect: https://app.com/callback#access_token=eyJ...&token_type=bearer
 
// ✅ Good: Authorization code exchange happens server-side
async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string
): Promise<TokenResponse> {
  const response = await fetch("https://auth.example.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.OAUTH_REDIRECT_URI!,
      client_id: process.env.OAUTH_CLIENT_ID!,
      code_verifier: codeVerifier,
    }),
  });
 
  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }
 
  return response.json();
}

Always validate the state parameter on the callback to prevent CSRF attacks against the OAuth flow itself. Never skip this step, even in development.

Server-Side Sessions: The Underrated Option

Sessions fell out of fashion when microservices took over, but they remain the most secure option for monolithic applications and backends-for-frontends. A session ID stored in an HTTP-only cookie with server-side state gives you instant revocation without the complexity of token blacklists.

tstypescript
import { Redis } from "ioredis";
import crypto from "crypto";
 
const redis = new Redis(process.env.REDIS_URL!);
const SESSION_TTL = 3600; // 1 hour
 
interface SessionData {
  userId: string;
  role: string;
  createdAt: number;
  lastActivity: number;
}
 
async function createSession(user: User): Promise<string> {
  const sessionId = crypto.randomBytes(32).toString("hex");
  const sessionData: SessionData = {
    userId: user.id,
    role: user.role,
    createdAt: Date.now(),
    lastActivity: Date.now(),
  };
 
  await redis.setex(
    `session:${sessionId}`,
    SESSION_TTL,
    JSON.stringify(sessionData)
  );
 
  return sessionId;
}
 
async function validateSession(
  sessionId: string
): Promise<SessionData | null> {
  const data = await redis.get(`session:${sessionId}`);
  if (!data) return null;
 
  const session: SessionData = JSON.parse(data);
  session.lastActivity = Date.now();
 
  await redis.setex(
    `session:${sessionId}`,
    SESSION_TTL,
    JSON.stringify(session)
  );
 
  return session;
}
 
async function revokeSession(sessionId: string): Promise<void> {
  await redis.del(`session:${sessionId}`);
}

The tradeoff is clear: sessions require a shared store (Redis, database) which adds a dependency and limits horizontal scaling. But you get immediate revocation, no token size overhead, and complete control over session lifecycle.

Middleware Patterns for Authentication

Authentication logic belongs in middleware, not scattered across route handlers. A clean middleware chain validates credentials, extracts identity, and attaches it to the request context.

tstypescript
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
 
interface AuthenticatedRequest extends NextRequest {
  user?: { sub: string; role: string };
}
 
function authMiddleware(
  handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) {
  return async (req: AuthenticatedRequest): Promise<NextResponse> => {
    const token = req.cookies.get("access_token")?.value;
 
    if (!token) {
      return NextResponse.json(
        { error: "Authentication required" },
        { status: 401 }
      );
    }
 
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!, {
        algorithms: ["HS256"],
        issuer: "api.example.com",
      }) as { sub: string; role: string };
 
      req.user = payload;
      return handler(req);
    } catch {
      return NextResponse.json(
        { error: "Invalid or expired token" },
        { status: 401 }
      );
    }
  };
}
 
function requireRole(...roles: string[]) {
  return (
    handler: (req: AuthenticatedRequest) => Promise<NextResponse>
  ) => {
    return authMiddleware(async (req: AuthenticatedRequest) => {
      if (!req.user || !roles.includes(req.user.role)) {
        return NextResponse.json(
          { error: "Insufficient permissions" },
          { status: 403 }
        );
      }
      return handler(req);
    });
  };
}

Notice the algorithms field in jwt.verify. Without it, an attacker could send a token signed with an "none" algorithm and bypass verification entirely. Always specify the expected algorithm explicitly.

Refresh Token Rotation and Revocation

Refresh tokens are long-lived and powerful. If one gets stolen, the attacker can mint new access tokens indefinitely. Refresh token rotation solves this by issuing a new refresh token with every access token refresh and invalidating the old one.

tstypescript
async function rotateRefreshToken(
  currentRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  let payload: { sub: string; jti: string };
 
  try {
    payload = jwt.verify(
      currentRefreshToken,
      process.env.JWT_REFRESH_SECRET!,
      { algorithms: ["HS256"] }
    ) as { sub: string; jti: string };
  } catch {
    throw new Error("Invalid refresh token");
  }
 
  const isRevoked = await redis.get(`revoked:${payload.jti}`);
  if (isRevoked) {
    await redis.del(`refresh_family:${payload.sub}`);
    throw new Error("Refresh token reuse detected — all sessions revoked");
  }
 
  await redis.setex(`revoked:${payload.jti}`, 604800, "true");
 
  const user = await getUserById(payload.sub);
  if (!user) throw new Error("User not found");
 
  return {
    accessToken: generateAccessToken(user),
    refreshToken: generateRefreshToken(user),
  };
}

The critical detail is detecting refresh token reuse. If a revoked token is presented again, it means the token was stolen—both the legitimate user and the attacker have a copy. The correct response is to revoke the entire token family and force re-authentication.

Key Takeaways

API authentication is a spectrum of tradeoffs. JWTs give you stateless verification at the cost of revocability. Sessions give you control at the cost of infrastructure. OAuth gives you delegation at the cost of complexity.

The non-negotiable principles remain the same regardless of which approach you choose: store tokens in HTTP-only cookies, keep access tokens short-lived, validate everything on the server, specify algorithms explicitly, implement refresh token rotation, and never put sensitive data in token payloads.

Security is not a feature you add at the end. It is a constraint you design around from the beginning. The authentication strategy you pick on day one shapes every API endpoint you build after it.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX