Authentication
Authentication is the process of verifying who a user is. Every secure API needs authentication before it can enforce authorization. The mechanism you choose shapes your security posture, user experience, and operational complexity.
Modern applications use a mix of strategies. JSON Web Tokens are popular for stateless APIs. Sessions remain common for server-rendered applications. OAuth2 and OpenID Connect power social login and enterprise single sign-on. Multi-factor authentication and passwordless methods reduce reliance on passwords.
This guide explains how each approach works, when to use it, and the security pitfalls to avoid. It is paired with the authorization guide, which covers what authenticated users are allowed to do.
A JSON Web Token, or JWT, is a compact, self-contained way to transmit claims between parties. It consists of three parts: a header, a payload, and a signature. The signature ensures the token has not been tampered with, provided the signing key is secret.
JWTs are convenient because the server does not need to query a session store for every request. The downside is that revoking a JWT before it expires is difficult. Short expiry times and refresh tokens are the standard mitigation.
| 1 | import jwt from "jsonwebtoken"; |
| 2 | |
| 3 | const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!; |
| 4 | const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!; |
| 5 | |
| 6 | interface TokenPayload { |
| 7 | sub: string; // user id |
| 8 | email: string; |
| 9 | scope: string; |
| 10 | jti: string; // unique token id for revocation |
| 11 | iat: number; |
| 12 | exp: number; |
| 13 | } |
| 14 | |
| 15 | export function issueAccessToken(user: User): string { |
| 16 | return jwt.sign( |
| 17 | { |
| 18 | sub: user.id, |
| 19 | email: user.email, |
| 20 | scope: "api:read api:write", |
| 21 | }, |
| 22 | ACCESS_TOKEN_SECRET, |
| 23 | { |
| 24 | expiresIn: "15m", |
| 25 | issuer: "forgelearn", |
| 26 | audience: "forgelearn-api", |
| 27 | } |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | export function verifyAccessToken(token: string): TokenPayload { |
| 32 | return jwt.verify(token, ACCESS_TOKEN_SECRET, { |
| 33 | issuer: "forgelearn", |
| 34 | audience: "forgelearn-api", |
| 35 | }) as TokenPayload; |
| 36 | } |
| 37 | |
| 38 | export function issueRefreshToken(user: User): string { |
| 39 | return jwt.sign( |
| 40 | { sub: user.id, jti: crypto.randomUUID() }, |
| 41 | REFRESH_TOKEN_SECRET, |
| 42 | { expiresIn: "7d" } |
| 43 | ); |
| 44 | } |
warning
Session-based authentication stores the user's state on the server and sends a session identifier to the client, usually in an HTTP-only cookie. The server looks up the session on every request, which makes revocation instant and supports richer session metadata.
Sessions are a natural fit for server-rendered applications and monolithic backends. They require a session store such as Redis or a database, which adds infrastructure but gives you complete control over active sessions.
| 1 | import session from "express-session"; |
| 2 | import RedisStore from "connect-redis"; |
| 3 | import { createClient } from "redis"; |
| 4 | |
| 5 | const redisClient = createClient({ url: process.env.REDIS_URL }); |
| 6 | redisClient.connect(); |
| 7 | |
| 8 | app.use( |
| 9 | session({ |
| 10 | store: new RedisStore({ client: redisClient }), |
| 11 | name: "sessionId", |
| 12 | secret: process.env.SESSION_SECRET!, |
| 13 | resave: false, |
| 14 | saveUninitialized: false, |
| 15 | cookie: { |
| 16 | httpOnly: true, |
| 17 | secure: process.env.NODE_ENV === "production", |
| 18 | sameSite: "lax", |
| 19 | maxAge: 1000 * 60 * 60 * 24, // 24 hours |
| 20 | }, |
| 21 | }) |
| 22 | ); |
| 23 | |
| 24 | app.post("/login", async (req, res) => { |
| 25 | const user = await authenticateUser(req.body.email, req.body.password); |
| 26 | if (!user) { |
| 27 | return res.status(401).json({ error: "Invalid credentials" }); |
| 28 | } |
| 29 | req.session.userId = user.id; |
| 30 | return res.json({ user: { id: user.id, email: user.email } }); |
| 31 | }); |
| 32 | |
| 33 | app.post("/logout", (req, res) => { |
| 34 | req.session.destroy(() => { |
| 35 | res.clearCookie("sessionId"); |
| 36 | res.json({ status: "logged out" }); |
| 37 | }); |
| 38 | }); |
best practice
OAuth2 is an authorization framework that lets users grant third-party applications limited access to their accounts without sharing passwords. OpenID Connect builds on OAuth2 to add identity claims, enabling social login and single sign-on.
The Authorization Code flow with PKCE is the recommended flow for native and single-page applications. The client redirects the user to the identity provider, the provider authenticates the user and returns an authorization code, and the client exchanges the code for tokens.
| 1 | // Authorization URL construction |
| 2 | function getAuthorizationUrl(codeChallenge: string, state: string): string { |
| 3 | const params = new URLSearchParams({ |
| 4 | client_id: "forgelearn-client", |
| 5 | response_type: "code", |
| 6 | scope: "openid profile email", |
| 7 | redirect_uri: "https://forgelearn.dev/auth/callback", |
| 8 | state, |
| 9 | code_challenge: codeChallenge, |
| 10 | code_challenge_method: "S256", |
| 11 | }); |
| 12 | return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; |
| 13 | } |
| 14 | |
| 15 | // Token exchange on callback |
| 16 | async function exchangeCodeForTokens(code: string, codeVerifier: string) { |
| 17 | const response = await fetch("https://oauth2.googleapis.com/token", { |
| 18 | method: "POST", |
| 19 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 20 | body: new URLSearchParams({ |
| 21 | grant_type: "authorization_code", |
| 22 | client_id: "forgelearn-client", |
| 23 | client_secret: process.env.OAUTH_CLIENT_SECRET!, |
| 24 | code, |
| 25 | redirect_uri: "https://forgelearn.dev/auth/callback", |
| 26 | code_verifier: codeVerifier, |
| 27 | }), |
| 28 | }); |
| 29 | return response.json(); |
| 30 | } |
danger
Single sign-on lets users authenticate once and access multiple applications. Enterprise environments commonly use SAML or OIDC-based SSO. When a user accesses your application, you redirect them to their organization's identity provider. After successful authentication, the provider sends a signed assertion or token back.
Implementing SSO from scratch is error-prone. Use established libraries such as Passport.js, Auth.js, or commercial identity platforms. Map identity provider attributes to your user model and provision accounts on first login.
| Protocol | Best For | Notes |
|---|---|---|
| SAML 2.0 | Enterprise legacy systems | XML assertions, mature |
| OpenID Connect | Modern SaaS, mobile | JWT-based, simpler than SAML |
note
Multi-factor authentication requires users to provide two or more factors: something they know, something they have, or something they are. Common second factors include time-based one-time passwords generated by authenticator apps, SMS codes, hardware security keys, and push notifications.
TOTP is the most common implementation. During enrollment, the server generates a secret and displays it as a QR code. The user scans it into an authenticator app, which generates six-digit codes that rotate every thirty seconds. The server validates the code against the same secret and clock.
| 1 | import { authenticator } from "otplib"; |
| 2 | |
| 3 | // Enroll a user in MFA |
| 4 | function generateMfaSecret(userId: string): { secret: string; qrCodeUrl: string } { |
| 5 | const secret = authenticator.generateSecret(); |
| 6 | const qrCodeUrl = authenticator.keyuri( |
| 7 | userId, |
| 8 | "ForgeLearn", |
| 9 | secret |
| 10 | ); |
| 11 | return { secret, qrCodeUrl }; |
| 12 | } |
| 13 | |
| 14 | // Verify a TOTP code |
| 15 | function verifyMfaCode(secret: string, code: string): boolean { |
| 16 | return authenticator.verify({ token: code, secret }); |
| 17 | } |
best practice
Passwordless authentication removes passwords entirely. Magic links sent by email, one-time codes sent by SMS, and WebAuthn passkeys are the most common methods. Passwordless reduces phishing risk, eliminates credential stuffing, and improves conversion.
Magic links are simple to implement but require a reliable email provider and are vulnerable to pre-fetching by email clients. Passkeys based on WebAuthn are the most secure option, using public key cryptography tied to a device.
info
Where you store tokens in the browser affects security. Local storage is convenient but accessible to JavaScript, making it vulnerable to XSS theft. HTTP-only cookies are more secure against XSS but require CSRF protection for state-changing requests.
| Storage | XSS Risk | CSRF Risk | Best For |
|---|---|---|---|
| Local storage | High | None | Non-sensitive, short-lived tokens |
| HTTP-only cookie | Low | Requires mitigation | Sessions, refresh tokens |
| Memory | Low | None | Short-lived access tokens |
danger