Authentication Patterns in TypeScript: From JWT to OIDC and Beyond
Fellow TypeScript enthusiasts, let's face it – authentication is often the unglamorous yet critical foundation we must get right in our applications. As TypeScript developers, we have a secret weapon: strong typing that can help us avoid the security pitfalls that plague many authentication implementations.
In this deep dive for TypeScript.Guru, we'll explore modern authentication patterns with strongly-typed implementations that leverage the full power of the language. Whether you're building a small side project or enterprise-grade systems, you'll find actionable patterns that combine security best practices with TypeScript's type safety.
Table of Contents
- JWT (JSON Web Tokens)
- OIDC (OpenID Connect)
- Magic Links
- OAuth 2.0
- Session-Based Authentication
- Passwordless Authentication
- Choosing the Right Pattern
- TypeScript Authentication Best Practices
JWT (JSON Web Tokens)
JSON Web Tokens (JWT) have become the darling of modern authentication, especially for building stateless APIs and SPAs. The beauty of JWTs in a TypeScript environment lies in how naturally they map to interfaces, making the entire authentication process more robust.
What is JWT?
A JWT is a compact, self-contained means for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
JWT Structure
A JWT consists of three parts separated by dots (.
):
- Header: Typically consists of the token type and the signing algorithm
- Payload: Contains the claims (statements about an entity and additional data)
- Signature: Used to verify that the sender of the JWT is who it says it is
Implementing JWT Authentication in TypeScript
Here's how we can implement JWT authentication in a Node.js/Express application with proper TypeScript typing:
// auth.ts
import jwt from 'jsonwebtoken';
import express from 'express';
// Define types for better type safety
interface UserPayload {
id: string;
email: string;
role: 'admin' | 'user' | 'guest'; // Using literal types for better type safety
iat?: number; // JWT automatically adds issued at timestamp
exp?: number; // JWT expiration timestamp
}
// Extend Express Request to avoid type assertions later
declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}
export class AuthService {
private readonly SECRET_KEY: string;
private readonly TOKEN_EXPIRY: string;
constructor() {
// Environmental configuration with fallbacks
this.SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
this.TOKEN_EXPIRY = process.env.JWT_EXPIRY || '1h'; // Token expires in 1 hour
}
// Generate a strongly-typed JWT token
generateToken(user: Omit<UserPayload, 'iat' | 'exp'>): string {
return jwt.sign(
{
id: user.id,
email: user.email,
role: user.role
},
this.SECRET_KEY,
{ expiresIn: this.TOKEN_EXPIRY }
);
}
// Verify a JWT token with proper typing
verifyToken(token: string): UserPayload | null {
try {
const payload = jwt.verify(token, this.SECRET_KEY) as UserPayload;
// Additional runtime type checking if needed
if (!payload.id || !payload.email || !payload.role) {
return null;
}
return payload;
} catch (error) {
// More specific error handling
if (error instanceof jwt.JsonWebTokenError) {
console.error('JWT Error:', error.message);
} else if (error instanceof jwt.TokenExpiredError) {
console.error('JWT Expired:', error.message);
}
return null;
}
}
}
// Type-safe middleware to protect routes
export const authenticateJWT = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ message: 'Authorization header missing' });
}
const token = authHeader.split(' ')[1]; // Bearer <token>
const authService = new AuthService();
const user = authService.verifyToken(token);
if (!user) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
// No more type assertion needed thanks to our Request interface extension
req.user = user;
next();
};
TypeScript Tip: By extending the Express.Request interface, we avoid type assertions (
req as any
) and get full IntelliSense support when accessingreq.user
in route handlers!
Using JWT Authentication in Your TypeScript Application
Let's integrate our type-safe JWT authentication into an Express application:
// app.ts
import express from 'express';
import { authenticateJWT, AuthService } from './auth';
// Define request body interface for better type checking
interface LoginRequest {
email: string;
password: string;
}
// Error response type
interface ErrorResponse {
message: string;
code?: string;
}
// Success response type
interface LoginResponse {
token: string;
expiresIn: number;
user: {
id: string;
email: string;
role: string;
};
}
const app = express();
app.use(express.json());
const authService = new AuthService();
// Login route with proper request/response typing
app.post<{}, LoginResponse | ErrorResponse, LoginRequest>('/login', (req, res) => {
const { email, password } = req.body;
// Verify credentials (simplified for this example)
if (email === '[email protected]' && password === 'password') {
const user = {
id: '123',
email: '[email protected]',
role: 'user' as const // Using const assertion for literal type
};
const token = authService.generateToken(user);
// Type-safe response
return res.json({
token,
expiresIn: 3600, // 1 hour in seconds
user: {
id: user.id,
email: user.email,
role: user.role
}
});
}
return res.status(401).json({ message: 'Invalid credentials' });
});
// Protected route with no type assertion needed
app.get('/protected', authenticateJWT, (req, res) => {
// TypeScript knows req.user exists and has the right shape
res.json({
message: 'You have access to this protected resource',
user: req.user
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
TypeScript's generic type parameters with Express route handlers enforce strict typing for request bodies and responses. This catches type mismatches at compile time rather than runtime, significantly improving your application's reliability.
Advantages of JWT in TypeScript Applications
- Stateless Architecture: No need to store sessions on the server, perfect for scalable microservices.
- Type Safety: Interfaces map cleanly to JWT payloads, reducing runtime errors.
- Scalability: Works exceptionally well in distributed architectures.
- Cross-domain: Easily share authentication across domains in your frontend/backend stack.
- Performance: Reduces database lookups for authentication checks in high-performance APIs.
Disadvantages of JWT
- Token Size: JWTs can be larger than session IDs, increasing your API payload size.
- Revocation Challenges: Cannot be invalidated before expiry unless using a token blacklist or database.
- Secret Management: Requires careful management of signing keys across your services.
- Type Safety Limitations: Compile-time checking doesn't validate runtime JWT data structure.
Security Warning: Remember that even with perfect interfaces, you should always validate JWT payloads at runtime! Type assertions (
as UserPayload
) provide compile-time confidence but no runtime guarantees when dealing with external tokens.
OIDC (OpenID Connect)
OpenID Connect (OIDC) is where the enterprise meets the modern web. When implemented with TypeScript, this complex authentication protocol becomes significantly more manageable through strong typing of tokens, claims, and providers.
What is OIDC?
OIDC extends OAuth 2.0 by adding an identity layer, allowing clients to verify the identity of end-users and obtain basic profile information. It introduces the concept of an ID token, which is a JWT containing claims about the authentication event and user. Strong typing with interfaces makes the otherwise complex protocol much more maintainable.
Key Components of OIDC
- Identity Provider (IdP): Authenticates users and issues ID tokens
- Relying Party (RP): The application that relies on the IdP for authentication
- ID Token: JWT containing user identity information
- UserInfo Endpoint: API for fetching additional user attributes
Implementing OIDC in TypeScript
Let's implement a robust OIDC client using TypeScript with the popular openid-client
library. We'll see how TypeScript's type system helps manage the complexity of OIDC:
// oidc-client.ts
import { Issuer, Client, TokenSet, UserinfoResponse } from 'openid-client';
import express from 'express';
import session from 'express-session';
// Define strongly-typed interfaces for our OIDC interactions
interface OIDCConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
discoveryUrl: string;
scope: string;
}
// Define the expected user info shape from your provider
interface OIDCUserInfo {
sub: string; // Subject - Unique identifier
email?: string; // Email may be present if requested in scope
email_verified?: boolean;
name?: string; // Full name may be present if profile scope requested
given_name?: string; // First name
family_name?: string; // Last name
picture?: string; // Profile picture URL
locale?: string; // User's locale
[key: string]: unknown; // Allow for provider-specific claims
}
// Define session with TypeScript to avoid type assertions
declare module 'express-session' {
interface SessionData {
state?: string;
tokens?: TokenSet;
userInfo?: OIDCUserInfo;
}
}
export class OIDCService {
private client: Client | null = null;
private readonly config: OIDCConfig;
constructor(config: Partial<OIDCConfig> = {}) {
// Merge provided config with defaults or environment variables
this.config = {
clientId: config.clientId || process.env.OIDC_CLIENT_ID || '',
clientSecret: config.clientSecret || process.env.OIDC_CLIENT_SECRET || '',
redirectUri: config.redirectUri || process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback',
discoveryUrl: config.discoveryUrl || process.env.OIDC_DISCOVERY_URL || '',
scope: config.scope || 'openid email profile'
};
// Validate required configuration
if (!this.config.clientId || !this.config.clientSecret || !this.config.discoveryUrl) {
throw new Error('Missing required OIDC configuration');
}
}
async initialize(): Promise<void> {
try {
// Discover the OIDC provider (e.g., Auth0, Okta, etc.)
const issuer = await Issuer.discover(this.config.discoveryUrl);
// Create an OIDC client with typed configuration
this.client = new issuer.Client({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uris: [this.config.redirectUri],
response_types: ['code'],
});
} catch (error) {
console.error('OIDC initialization failed:', error instanceof Error ? error.message : String(error));
throw error;
}
}
getAuthorizationUrl(state: string): string {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
return this.client.authorizationUrl({
scope: this.config.scope,
state,
});
}
async handleCallback(code: string, state: string, savedState: string): Promise<TokenSet> {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
if (state !== savedState) {
throw new Error('State mismatch');
}
return await this.client.callback(this.config.redirectUri, { code, state });
}
async getUserInfo(accessToken: string): Promise<OIDCUserInfo> {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
// Type the response and validate required fields
const userInfo = await this.client.userinfo(accessToken) as UserinfoResponse;
// Validate minimum required fields
if (!userInfo.sub) {
throw new Error('Invalid user info response');
}
return userInfo as OIDCUserInfo;
}
}
// Express middleware for OIDC authentication with full TypeScript support
export const setupOIDC = async (app: express.Application): Promise<OIDCService> => {
// Create and initialize the OIDC service
const oidcService = new OIDCService({
// Configuration can be passed directly or pulled from environment variables
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
discoveryUrl: process.env.OIDC_DISCOVERY_URL || 'https://your-identity-provider/.well-known/openid-configuration',
});
await oidcService.initialize();
// Setup session middleware with proper typing
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret',
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
httpOnly: true
}
}));
// Login route - initiates OIDC authentication flow
app.get('/login', (req, res) => {
// Generate a cryptographically secure state
const state = crypto.randomBytes(16).toString('hex');
req.session.state = state;
const authUrl = oidcService.getAuthorizationUrl(state);
res.redirect(authUrl);
});
// Callback route - handles the OAuth/OIDC response
app.get('/callback', async (req, res) => {
try {
// Type-safe query parameter extraction
const { code, state } = req.query as { code?: string, state?: string };
// Validate required parameters
if (!code || !state) {
return res.status(400).json({ error: 'Missing required parameters' });
}
const savedState = req.session.state;
if (!savedState) {
return res.status(400).json({ error: 'No authentication flow in progress' });
}
// Handle the callback with proper error handling
const tokens = await oidcService.handleCallback(code, state, savedState);
req.session.tokens = tokens;
// Ensure access token is available
if (!tokens.access_token) {
return res.status(500).json({ error: 'No access token received' });
}
// Fetch user info with type-safe response
const userInfo = await oidcService.getUserInfo(tokens.access_token);
req.session.userInfo = userInfo;
res.redirect('/profile');
} catch (error) {
console.error('OIDC callback error:', error instanceof Error ? error.message : String(error));
// Provide a user-friendly error message
res.status(500).render('error', {
message: 'Authentication failed',
details: process.env.NODE_ENV === 'development' ? error : undefined
});
}
});
// Profile route - displays authenticated user info
app.get('/profile', (req, res) => {
// Type-safe session check
if (!req.session.userInfo) {
return res.redirect('/login');
}
res.json(req.session.userInfo);
});
// Logout route - properly handles OIDC logout if supported
app.get('/logout', async (req, res) => {
try {
// Check if end_session_endpoint is available
if (oidcService.client?.issuer.metadata.end_session_endpoint && req.session.tokens?.id_token) {
// Get logout URL
const logoutUrl = oidcService.client.endSessionUrl({
id_token_hint: req.session.tokens.id_token,
post_logout_redirect_uri: `${req.protocol}://${req.headers.host}/`
});
// Clear the session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
}
// Redirect to the OIDC provider's logout endpoint
res.redirect(logoutUrl);
});
} else {
// Simple session destruction if OIDC logout not available
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
}
res.redirect('/');
});
}
} catch (error) {
console.error('Logout error:', error);
req.session.destroy(() => res.redirect('/'));
}
});
return oidcService;
};
// TypeScript type guard for checking token access
function hasAccessToken(tokens: TokenSet): tokens is TokenSet & { access_token: string } {
return tokens.access_token !== undefined;
}
Usage in Your Application
// app.ts
import express from 'express';
import { setupOIDC } from './oidc-client';
async function bootstrap() {
const app = express();
// Setup OIDC authentication
await setupOIDC(app);
app.get('/', (req, res) => {
res.send(`
<h1>OIDC Authentication Example</h1>
<a href="/login">Login with OIDC</a>
`);
});
app.listen(3000, () => console.log('Server running on port 3000'));
}
bootstrap().catch(console.error);
Advantages of OIDC with TypeScript
- Type Safety for Complex Protocols: Interfaces can model the complex OIDC structures and responses.
- Enterprise Integration: Perfect for enterprise applications requiring SSO.
- Standardized Claims: OIDC claims map cleanly to interfaces, providing consistency.
- Separation of Concerns: Offloads authentication to specialized providers, letting you focus on your business logic.
- Strong Security Model: Built on proven standards with strong typing helping enforce proper implementation.
Disadvantages of OIDC
- Implementation Complexity: Even with TypeScript, OIDC remains complex to implement correctly.
- Library Dependencies: Most OIDC libraries have numerous dependencies that can create package conflicts.
- Type Definition Challenges: Some OIDC providers have non-standard responses that don't match library type definitions.
- Learning Curve: Steeper learning curve than simpler auth methods.
Implementation Insight: OIDC shines in enterprise applications where integration with existing identity systems is required. While JWT might be simpler for smaller projects, OIDC offers a more complete solution for complex authentication needs, with strong typing helping manage the complexity.
Magic Links: The TypeScript Developer's Secret Weapon
Magic links provide an elegant passwordless authentication experience that combines excellent security with minimal user friction. When implemented with TypeScript, they become especially robust due to proper typing of tokens and verification processes.
What are Magic Links?
Magic links are unique URLs containing cryptographically secure tokens sent to users via email. When a user clicks the link, your backend verifies the token and logs the user in without requiring a password. The beauty of this approach is that it can be implemented with robust type safety, avoiding many common security pitfalls.
Implementing Magic Links in TypeScript
Let's build a properly typed, robust magic link authentication system that takes full advantage of TypeScript's capabilities:
// magic-link.ts
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Pool, QueryResult } from 'pg';
import express from 'express';
// Define our token structure with proper typing
interface MagicLinkToken {
userId: string;
email: string; // Store email to prevent token sharing
token: string;
expires: Date;
used: boolean; // Track if the token has been used
}
// Error types for better error handling
enum MagicLinkErrorType {
USER_NOT_FOUND = 'USER_NOT_FOUND',
EMAIL_FAILURE = 'EMAIL_FAILURE',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
TOKEN_USED = 'TOKEN_USED',
TOKEN_INVALID = 'TOKEN_INVALID',
INTERNAL_ERROR = 'INTERNAL_ERROR'
}
class MagicLinkError extends Error {
constructor(public type: MagicLinkErrorType, message: string) {
super(message);
this.name = 'MagicLinkError';
}
}
// Define database schemas as types for clarity
interface UserRecord {
id: string;
email: string;
name?: string;
created_at: Date;
}
interface TokenRecord {
id: string;
user_id: string;
token: string;
expires_at: Date;
used: boolean;
created_at: Date;
}
export class MagicLinkService {
private readonly TOKEN_EXPIRY = 15 * 60 * 1000; // 15 minutes
private readonly mailer: nodemailer.Transporter;
private readonly db: Pool;
constructor() {
// Setup email transporter with proper type checking
this.mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Setup database connection
this.db = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Create tables if they don't exist - a real app would use migrations
this.initializeDatabase().catch(err => {
console.error('Failed to initialize database:', err);
throw err;
});
}
private async initializeDatabase(): Promise<void> {
// Create users table
await this.db.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
// Create magic_link_tokens table
await this.db.query(`
CREATE TABLE IF NOT EXISTS magic_link_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
// Create index for faster token lookups
await this.db.query(`
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token ON magic_link_tokens(token)
`);
// Setup cleanup function - delete tokens older than 24 hours
await this.db.query(`
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS void AS $
BEGIN
DELETE FROM magic_link_tokens
WHERE expires_at < NOW() OR created_at < NOW() - INTERVAL '24 hours';
END;
$ LANGUAGE plpgsql;
`);
// Schedule regular cleanup
await this.db.query(`
DO $
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_stat_activity
WHERE query LIKE '%cleanup_expired_tokens%' AND state = 'active'
) THEN
PERFORM pg_notify('cleanup_channel', '');
END IF;
END $;
`);
}
async sendMagicLink(email: string, baseUrl: string = 'http://localhost:3000'): Promise<boolean> {
try {
// Find or create user by email - upsert pattern
const userResult = await this.db.query<UserRecord>(
`INSERT INTO users (email)
VALUES ($1)
ON CONFLICT (email) DO UPDATE SET email = $1
RETURNING id, email`,
[email]
);
if (userResult.rows.length === 0) {
throw new MagicLinkError(
MagicLinkErrorType.INTERNAL_ERROR,
'Failed to create or find user'
);
}
const userId = userResult.rows[0].id;
// Generate a cryptographically secure token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + this.TOKEN_EXPIRY);
// Store token in database with proper typing
await this.db.query<TokenRecord>(
`INSERT INTO magic_link_tokens (user_id, token, expires_at)
VALUES ($1, $2, $3)`,
[userId, token, expiresAt]
);
// Create magic link URL with properly encoded token
const magicLink = new URL(`/login/verify`, baseUrl);
magicLink.searchParams.append('token', token);
// Send email with typed nodemailer interface
await this.mailer.sendMail({
from: `"${process.env.APP_NAME || 'Your App'}" <${process.env.EMAIL_FROM || '[email protected]'}>`,
to: email,
subject: 'Your Secure Login Link',
text: `Click this link to log in: ${magicLink.toString()}\n\nThis link will expire in 15 minutes and can only be used once.`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Secure Login</h2>
<p>Click the button below to securely log in to your account.</p>
<a href="${magicLink.toString()}"
style="display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 4px; font-weight: bold;">
Log In Securely
</a>
<p style="color: #666; margin-top: 20px; font-size: 0.8em;">
This link will expire in 15 minutes and can only be used once.
</p>
</div>
`,
});
return true;
} catch (error) {
// Type-safe error handling
if (error instanceof MagicLinkError) {
console.error(`Magic link error (${error.type}):`, error.message);
} else {
console.error('Error sending magic link:', error instanceof Error ? error.message : String(error));
}
return false;
}
}
async verifyToken(token: string): Promise<string | null> {
try {
// Begin transaction
const client = await this.db.connect();
try {
// Start transaction
await client.query('BEGIN');
// Find and update the token atomically
const tokenResult = await client.query<TokenRecord>(
`UPDATE magic_link_tokens
SET used = TRUE
WHERE token = $1
AND expires_at > NOW()
AND used = FALSE
RETURNING user_id`,
[token]
);
if (tokenResult.rows.length === 0) {
// Check if token exists but was expired or used
const checkResult = await client.query<TokenRecord>(
`SELECT expires_at, used FROM magic_link_tokens WHERE token = $1`,
[token]
);
if (checkResult.rows.length > 0) {
const record = checkResult.rows[0];
if (record.used) {
throw new MagicLinkError(MagicLinkErrorType.TOKEN_USED, 'Token has already been used');
}
if (record.expires_at < new Date()) {
throw new MagicLinkError(MagicLinkErrorType.TOKEN_EXPIRED, 'Token has expired');
}
} else {
throw new MagicLinkError(MagicLinkErrorType.TOKEN_INVALID, 'Invalid token');
}
return null;
}
const userId = tokenResult.rows[0].user_id;
// Commit transaction
await client.query('COMMIT');
return userId;
} catch (error) {
// Rollback transaction on error
await client.query('ROLLBACK');
throw error;
} finally {
// Release client back to pool
client.release();
}
} catch (error) {
if (error instanceof MagicLinkError) {
console.error(`Token verification error (${error.type}):`, error.message);
} else {
console.error('Error verifying token:', error instanceof Error ? error.message : String(error));
}
return null;
}
}
}
// Type for our authenticated user
interface AuthenticatedUser {
id: string;
email: string;
name?: string;
}
// Extend Express Request to include our authenticated user
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
// Express middleware for magic link authentication with proper TypeScript types
export const setupMagicLinkAuth = (app: express.Application): MagicLinkService => {
const magicLinkService = new MagicLinkService();
// Define request body types for enhanced type safety
interface MagicLinkRequest {
email: string;
redirectTo?: string;
}
interface MagicLinkResponse {
success: boolean;
message: string;
}
// Setup route with proper typing
app.post<{}, MagicLinkResponse, MagicLinkRequest>('/login/email', async (req, res) => {
const { email, redirectTo } = req.body;
if (!email) {
return res.status(400).json({
success: false,
message: 'Email is required'
});
}
// Validate email format using TypeScript-friendly regex
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
// Get the base URL from request for proper link generation
const baseUrl = `${req.protocol}://${req.get('host')}`;
// Store redirectTo in session if provided
if (redirectTo) {
// Validate redirectTo to prevent open redirects
const isValidRedirect = redirectTo.startsWith('/') && !redirectTo.includes('//');
if (isValidRedirect) {
req.session.redirectTo = redirectTo;
}
}
// Send the magic link
const sent = await magicLinkService.sendMagicLink(email, baseUrl);
// Always return success=true to prevent email enumeration attacks
res.json({
success: true,
message: 'If your email exists in our system, you will receive a magic link shortly'
});
});
// Token verification endpoint with proper error handling
app.get('/login/verify', async (req, res) => {
const token = req.query.token as string | undefined;
if (!token) {
return res.status(400).render('error', {
title: 'Invalid Link',
message: 'The login link is invalid or missing a token.'
});
}
const userId = await magicLinkService.verifyToken(token);
if (userId) {
// Fetch user details from database for the session
try {
const userResult = await magicLinkService['db'].query<AuthenticatedUser>(
'SELECT id, email, name FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length > 0) {
const user = userResult.rows[0];
// Set type-safe session values
req.session.userId = userId;
req.session.user = user;
req.session.isAuthenticated = true;
req.session.authMethod = 'magic-link';
req.session.authTime = new Date().toISOString();
// Check for and use redirectTo if available
const redirectTo = req.session.redirectTo || '/dashboard';
delete req.session.redirectTo; // Clean up after use
return res.redirect(redirectTo);
}
} catch (error) {
console.error('Error fetching user details:', error instanceof Error ? error.message : String(error));
}
// Fallback if user details couldn't be fetched
req.session.userId = userId;
req.session.isAuthenticated = true;
res.redirect('/dashboard');
} else {
// Provide a user-friendly error page
res.status(400).render('error', {
title: 'Invalid or Expired Link',
message: 'This login link is invalid or has expired. Please request a new one.',
actionLink: '/login',
actionText: 'Return to Login'
});
}
});
// Add a middleware that loads the user on every request if authenticated
app.use(async (req, res, next) => {
if (req.session.isAuthenticated && req.session.userId && !req.user) {
try {
const userResult = await magicLinkService['db'].query<AuthenticatedUser>(
'SELECT id, email, name FROM users WHERE id = $1',
[req.session.userId]
);
if (userResult.rows.length > 0) {
req.user = userResult.rows[0];
}
} catch (error) {
console.error('Error loading user:', error instanceof Error ? error.message : String(error));
}
}
next();
});
return magicLinkService;
};
// Type guard for checking if user is authenticated
function isAuthenticated(req: express.Request): boolean {
return req.session.isAuthenticated === true && !!req.user;
}
Usage in Your Application
// app.ts
import express from 'express';
import session from 'express-session';
import { setupMagicLinkAuth } from './magic-link';
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret',
resave: false,
saveUninitialized: true,
}));
// Setup magic link authentication
const magicLinkService = setupMagicLinkAuth(app);
app.get('/', (req, res) => {
res.send(`
<h1>Magic Link Authentication Example</h1>
<form action="/login/email" method="post">
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Send Magic Link</button>
</form>
`);
});
app.get('/dashboard', (req, res) => {
if (!(req.session as any).isAuthenticated) {
return res.redirect('/');
}
res.send(`
<h1>Dashboard</h1>
<p>You are logged in with user ID: ${(req.session as any).userId}</p>
<a href="/logout">Logout</a>
`);
});
app.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Advantages of Magic Links with TypeScript
- Type Safety: Interfaces perfectly model magic link tokens and user states.
- Secure by Design: No passwords to hash, compare, or store means fewer security vulnerabilities.
- Implementation Clarity: Strong typing makes the token generation, storage, and validation process explicit.
- Perfect for Modern Applications: Matches the stateless nature of modern backends.
- Transaction Safety: Proper typing helps ensure atomicity when dealing with token validation.
Disadvantages of Magic Links
- Email Dependency: Still requires reliable email delivery, which can introduce latency.
- Complex State Management: Requires careful handling of token states.
- Development Overhead: More initial code to write compared to simple password systems.
- Delivery Challenges: Email deliverability issues can confuse users.
Developer Insight: Magic links are compelling when you want to provide an excellent UX while eliminating password-related security issues. They shine in applications where security is important but immediate authentication isn't required. TypeScript provides a solid foundation for implementing them safely by ensuring token generation, storage, and verification happens exactly as intended.
OAuth 2.0
OAuth 2.0 is an authorization framework that enables third-party applications to access resources on behalf of users without exposing credentials.
What is OAuth 2.0?
OAuth 2.0 is a protocol that allows a user to grant a third-party application limited access to their resources on another service, without sharing their credentials. It's commonly used for social login (e.g., "Sign in with Google").
Key Components of OAuth 2.0
- Resource Owner: The user who owns the data
- Client: The application requesting access to resources
- Authorization Server: Issues tokens after authenticating the user
- Resource Server: Hosts the protected resources
- Access Token: Token used to access protected resources
Implementing OAuth 2.0 in TypeScript
Here's an implementation of OAuth 2.0 authentication with GitHub:
// oauth.ts
import axios from 'axios';
import express from 'express';
import session from 'express-session';
interface OAuthConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
scope: string;
}
export class OAuthService {
private readonly config: OAuthConfig;
constructor(config: OAuthConfig) {
this.config = config;
}
getAuthorizationUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scope,
state,
response_type: 'code',
});
return `${this.config.authorizationEndpoint}?${params.toString()}`;
}
async getAccessToken(code: string): Promise<string> {
const params = new URLSearchParams({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
redirect_uri: this.config.redirectUri,
grant_type: 'authorization_code',
});
const response = await axios.post(this.config.tokenEndpoint, params.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
});
return response.data.access_token;
}
async getUserInfo(accessToken: string): Promise<any> {
const response = await axios.get(this.config.userInfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
}
}
// Express middleware for OAuth authentication with GitHub
export const setupGitHubOAuth = (app: express.Application): OAuthService => {
const oauthService = new OAuthService({
clientId: process.env.GITHUB_CLIENT_ID || 'your-client-id',
clientSecret: process.env.GITHUB_CLIENT_SECRET || 'your-client-secret',
redirectUri: 'http://localhost:3000/auth/github/callback',
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
userInfoEndpoint: 'https://api.github.com/user',
scope: 'user:email',
});
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret',
resave: false,
saveUninitialized: true,
}));
app.get('/auth/github', (req, res) => {
const state = Math.random().toString(36).substring(2, 15);
(req.session as any).oauthState = state;
const authUrl = oauthService.getAuthorizationUrl(state);
res.redirect(authUrl);
});
app.get('/auth/github/callback', async (req, res) => {
try {
const { code, state } = req.query as { code: string, state: string };
const savedState = (req.session as any).oauthState;
if (state !== savedState) {
return res.status(400).json({ message: 'State mismatch' });
}
// Exchange code for access token
const accessToken = await oauthService.getAccessToken(code);
(req.session as any).accessToken = accessToken;
// Get user information
const userInfo = await oauthService.getUserInfo(accessToken);
(req.session as any).userInfo = userInfo;
(req.session as any).isAuthenticated = true;
res.redirect('/profile');
} catch (error) {
console.error('OAuth callback error:', error);
res.status(500).json({ message: 'Authentication failed' });
}
});
return oauthService;
};
Usage in Your Application
// app.ts
import express from 'express';
import { setupGitHubOAuth } from './oauth';
const app = express();
// Setup GitHub OAuth authentication
const oauthService = setupGitHubOAuth(app);
app.get('/', (req, res) => {
res.send(`
<h1>OAuth 2.0 Authentication Example</h1>
<a href="/auth/github">Login with GitHub</a>
`);
});
app.get('/profile', (req, res) => {
if (!(req.session as any).isAuthenticated) {
return res.redirect('/');
}
const userInfo = (req.session as any).userInfo;
res.send(`
<h1>Profile</h1>
<p>Name: ${userInfo.name || 'N/A'}</p>
<p>Login: ${userInfo.login}</p>
<img src="${userInfo.avatar_url}" width="100" />
<p><a href="/logout">Logout</a></p>
`);
});
app.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Advantages of OAuth 2.0
- Delegated authorization: Provides access to resources without sharing credentials
- Wide adoption: Supported by most major services
- User experience: Easy "Sign in with X" experience
- Selective permissions: Granular control over resource access
Disadvantages of OAuth 2.0
- Complexity: More complex to implement than basic authentication
- Dependency: Relies on external providers
- Security considerations: Requires careful implementation to avoid vulnerabilities
Session-Based Authentication
Session-based authentication is a traditional approach where the server maintains session state for authenticated users.
What is Session-Based Authentication?
In session-based authentication, the server creates a session for the user after successful authentication, stores session data on the server, and provides the user with a session ID (typically stored in a cookie).
Implementing Session-Based Authentication in TypeScript
Here's a simple implementation using Express and TypeScript:
// session-auth.ts
import express from 'express';
import session from 'express-session';
import bcrypt from 'bcrypt';
import { Pool } from 'pg';
export class SessionAuthService {
private readonly db: Pool;
constructor() {
// Setup database connection
this.db = new Pool({
connectionString: process.env.DATABASE_URL,
});
}
async authenticateUser(username: string, password: string): Promise<any | null> {
try {
// Find user by username
const result = await this.db.query(
'SELECT id, username, password_hash FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return null; // User not found
}
const user = result.rows[0];
// Verify password
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
return null; // Password doesn't match
}
// Return user without sensitive data
const { password_hash, ...userWithoutPassword } = user;
return userWithoutPassword;
} catch (error) {
console.error('Authentication error:', error);
return null;
}
}
async registerUser(username: string, password: string): Promise<any | null> {
try {
// Check if username already exists
const existingUser = await this.db.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (existingUser.rows.length > 0) {
return null; // Username already taken
}
// Hash the password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Insert the new user
const result = await this.db.query(
'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username',
[username, passwordHash]
);
return result.rows[0];
} catch (error) {
console.error('Registration error:', error);
return null;
}
}
}
// Express middleware for session-based authentication
export const setupSessionAuth = (app: express.Application): SessionAuthService => {
const authService = new SessionAuthService();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
// Authentication middleware
const requireAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!(req.session as any).user) {
return res.status(401).json({ message: 'Authentication required' });
}
next();
};
// Login route
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
const user = await authService.authenticateUser(username, password);
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Store user in session
(req.session as any).user = user;
res.json({ message: 'Login successful', user });
});
// Register route
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
const user = await authService.registerUser(username, password);
if (!user) {
return res.status(400).json({ message: 'Registration failed' });
}
res.status(201).json({ message: 'Registration successful', user });
});
// Logout route
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.json({ message: 'Logout successful' });
});
});
// Protected route example
app.get('/profile', requireAuth, (req, res) => {
res.json({ user: (req.session as any).user });
});
return authService;
};
Usage in Your Application
// app.ts
import express from 'express';
import { setupSessionAuth } from './session-auth';
const app = express();
// Setup session-based authentication
const authService = setupSessionAuth(app);
app.get('/', (req, res) => {
res.send(`
<h1>Session-Based Authentication Example</h1>
<h2>Login</h2>
<form id="loginForm">
<input type="text" name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Login</button>
</form>
<h2>Register</h2>
<form id="registerForm">
<input type="text" name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Register</button>
</form>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.get('username'),
password: formData.get('password')
})
});
const data = await response.json();
alert(data.message);
if (response.ok) {
window.location.href = '/profile';
}
});
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const response = await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.get('username'),
password: formData.get('password')
})
});
const data = await response.json();
alert(data.message);
});
</script>
`);
});
app.listen(3000, () => console.log('Server running on port 3000'));
Advantages of Session-Based Authentication
- Server control: Easy to invalidate sessions
- Stateful: Full control over the session lifecycle
- Performance: Small session IDs rather than larger tokens
- Familiarity: Well-established pattern with broad support
Disadvantages of Session-Based Authentication
- Scalability challenges: Requires session storage on the server
- Cross-domain issues: Cookies have limitations across domains
- CSRF vulnerability: Requires additional protection
- Mobile applications: Challenges with cookie handling
Passwordless Authentication
Passwordless authentication eliminates passwords entirely, relying on other verification methods like one-time codes, biometrics, or security keys.
What is Passwordless Authentication?
Passwordless authentication uses alternatives to passwords for verifying user identity. Common methods include:
- One-time codes sent via SMS or email
- Magic links (covered earlier)
- Biometrics (fingerprint, face recognition)
- Hardware security keys (FIDO2/WebAuthn)
Implementing WebAuthn in TypeScript
Here's an implementation of WebAuthn (FIDO2) authentication:
// webauthn.ts
import express from 'express';
import { Pool } from 'pg';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import {
RegistrationResponseJSON,
AuthenticationResponseJSON
} from '@simplewebauthn/typescript-types';
import base64url from 'base64url';
// RPServer represents the Relying Party (RP) server
export class WebAuthnService {
private readonly db: Pool;
private readonly rpName = 'TypeScript Auth Demo';
private readonly rpID = 'localhost';
private readonly origin = 'http://localhost:3000';
constructor() {
// Setup database connection
this.db = new Pool({
connectionString: process.env.DATABASE_URL,
});
}
// Generate registration options
async generateRegistrationOptions(userId: string, username: string): Promise<any> {
// Get existing authenticators for this user
const result = await this.db.query(
'SELECT credential_id FROM user_authenticators WHERE user_id = $1',
[userId]
);
const excludeCredentials = result.rows.map(row => ({
id: base64url.toBuffer(row.credential_id),
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal'],
}));
// Generate registration options
const options = generateRegistrationOptions({
rpName: this.rpName,
rpID: this.rpID,
userID: userId,
userName: username,
userDisplayName: username,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'preferred',
requireResidentKey: false,
},
});
// Save challenge to verify later
await this.db.query(
'INSERT INTO webauthn_challenges (user_id, challenge) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET challenge = $2',
[userId, options.challenge]
);
return options;
}
// Verify registration response
async verifyRegistration(userId: string, response: RegistrationResponseJSON): Promise<boolean> {
try {
// Get stored challenge
const challengeResult = await this.db.query(
'SELECT challenge FROM webauthn_challenges WHERE user_id = $1',
[userId]
);
if (challengeResult.rows.length === 0) {
return false;
}
const expectedChallenge = challengeResult.rows[0].challenge;
// Verify the registration
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
});
if (!verification.verified) {
return false;
}
// Store the authenticator
const { credentialID, credentialPublicKey } = verification.registrationInfo!;
await this.db.query(
'INSERT INTO user_authenticators (user_id, credential_id, public_key, counter) VALUES ($1, $2, $3, $4)',
[
userId,
base64url.encode(credentialID),
base64url.encode(credentialPublicKey),
verification.registrationInfo!.counter,
]
);
return true;
} catch (error) {
console.error('WebAuthn registration verification error:', error);
return false;
}
}
// Generate authentication options
async generateAuthenticationOptions(username: string): Promise<any> {
// Find user by username
const userResult = await this.db.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const userId = userResult.rows[0].id;
// Get user's authenticators
const authenticatorsResult = await this.db.query(
'SELECT credential_id FROM user_authenticators WHERE user_id = $1',
[userId]
);
if (authenticatorsResult.rows.length === 0) {
throw new Error('No authenticators registered for this user');
}
const allowCredentials = authenticatorsResult.rows.map(row => ({
id: base64url.toBuffer(row.credential_id),
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal'],
}));
// Generate authentication options
const options = generateAuthenticationOptions({
rpID: this.rpID,
allowCredentials,
userVerification: 'preferred',
});
// Save challenge
await this.db.query(
'INSERT INTO webauthn_challenges (user_id, challenge) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET challenge = $2',
[userId, options.challenge]
);
return { options, userId };
}
// Verify authentication response
async verifyAuthentication(userId: string, response: AuthenticationResponseJSON): Promise<boolean> {
try {
// Get challenge
const challengeResult = await this.db.query(
'SELECT challenge FROM webauthn_challenges WHERE user_id = $1',
[userId]
);
if (challengeResult.rows.length === 0) {
return false;
}
const expectedChallenge = challengeResult.rows[0].challenge;
// Get authenticator
const authenticatorResult = await this.db.query(
'SELECT credential_id, public_key, counter FROM user_authenticators WHERE credential_id = $1',
[response.id]
);
if (authenticatorResult.rows.length === 0) {
return false;
}
const authenticator = authenticatorResult.rows[0];
// Verify the authentication
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
authenticator: {
credentialID: base64url.toBuffer(authenticator.credential_id),
credentialPublicKey: base64url.toBuffer(authenticator.public_key),
counter: authenticator.counter,
},
});
if (!verification.verified) {
return false;
}
// Update counter
await this.db.query(
'UPDATE user_authenticators SET counter = $1 WHERE credential_id = $2',
[verification.authenticationInfo.newCounter, response.id]
);
return true;
} catch (error) {
console.error('WebAuthn authentication verification error:', error);
return false;
}
}
}
// Express middleware for WebAuthn
export const setupWebAuthn = (app: express.Application): WebAuthnService => {
const webAuthnService = new WebAuthnService();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET || 'your-session-secret',
resave: false,
saveUninitialized: true,
}));
// Registration endpoints
app.post('/webauthn/register/options', async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: 'Username is required' });
}
// For simplicity, create a user if it doesn't exist
const userResult = await pool.query(
'INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO UPDATE SET username = $1 RETURNING id',
[username]
);
const userId = userResult.rows[0].id;
// Generate registration options
const options = await webAuthnService.generateRegistrationOptions(userId, username);
// Store user ID in session
(req.session as any).userId = userId;
res.json(options);
} catch (error) {
console.error('WebAuthn registration options error:', error);
res.status(500).json({ message: 'Failed to generate registration options' });
}
});
app.post('/webauthn/register/verify', async (req, res) => {
try {
const userId = (req.session as any).userId;
if (!userId) {
return res.status(400).json({ message: 'Registration session expired' });
}
const response = req.body as RegistrationResponseJSON;
const verified = await webAuthnService.verifyRegistration(userId, response);
if (verified) {
// Set authenticated user in session
(req.session as any).isAuthenticated = true;
(req.session as any).userId = userId;
res.json({ message: 'Registration successful' });
} else {
res.status(400).json({ message: 'Registration failed' });
}
} catch (error) {
console.error('WebAuthn registration verification error:', error);
res.status(500).json({ message: 'Registration failed' });
}
});
// Authentication endpoints
app.post('/webauthn/login/options', async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).json({ message: 'Username is required' });
}
const { options, userId } = await webAuthnService.generateAuthenticationOptions(username);
// Store user ID in session
(req.session as any).userId = userId;
res.json(options);
} catch (error) {
console.error('WebAuthn login options error:', error);
res.status(500).json({ message: 'Failed to generate authentication options' });
}
});
app.post('/webauthn/login/verify', async (req, res) => {
try {
const userId = (req.session as any).userId;
if (!userId) {
return res.status(400).json({ message: 'Authentication session expired' });
}
const response = req.body as AuthenticationResponseJSON;
const verified = await webAuthnService.verifyAuthentication(userId, response);
if (verified) {
// Set authenticated user in session
(req.session as any).isAuthenticated = true;
(req.session as any).userId = userId;
res.json({ message: 'Authentication successful' });
} else {
res.status(400).json({ message: 'Authentication failed' });
}
} catch (error) {
console.error('WebAuthn authentication verification error:', error);
res.status(500).json({ message: 'Authentication failed' });
}
});
return webAuthnService;
};
Advantages of Passwordless Authentication
- Security: Eliminates password-related vulnerabilities
- User experience: Simplified login process
- Reduced support: No more password resets
- Phishing resistance: WebAuthn is highly resistant to phishing
Disadvantages of Passwordless Authentication
- Complexity: More complex to implement than password-based auth
- Fallback methods: May still need alternative methods
- Adoption challenges: Users may be unfamiliar with new methods
- Device dependency: Often requires specific device capabilities
Choosing the Right Pattern
When selecting an authentication pattern for your TypeScript application, consider these factors:
Security Requirements
- High security needs: Consider WebAuthn, OIDC with MFA, or JWT with short expiry
- Standard security: JWT, session-based, or OAuth 2.0 are good choices
- Consumer applications: Magic links or social login via OAuth 2.0
Application Architecture
- Microservices: JWT or OIDC work well for distributed systems
- Monolithic: Session-based authentication is often simplest
- Mobile/SPA: JWT or OAuth 2.0 for easy cross-platform integration
User Experience
- Frictionless UX: Magic links or social login
- Enterprise users: OIDC with SSO capabilities
- Security-conscious users: WebAuthn or multi-factor authentication
Development Complexity
- Simple implementation: Session-based authentication
- Standard complexity: JWT implementation
- More complex: OIDC, WebAuthn, or custom OAuth 2.0
Security Best Practices
Regardless of the authentication pattern you choose, follow these security best practices:
1. Transport Security
- Always use HTTPS: Encrypt all authentication traffic
- Secure cookies: Use the
Secure
andHttpOnly
flags - HSTS headers: Implement HTTP Strict Transport Security
2. Token Security
- Short-lived tokens: Set appropriate expiration times
- Token storage: Store tokens securely (httpOnly cookies preferred over localStorage)
- Refresh token rotation: Rotate refresh tokens after use
3. Implementation Security
- Input validation: Validate all user inputs
- Rate limiting: Implement rate limiting on authentication endpoints
- Logging and monitoring: Track authentication events for security analysis
4. Password Security (if used)
- Strong hashing: Use bcrypt, Argon2, or scrypt with appropriate settings
- Password policies: Encourage strong passwords without excessive complexity
- MFA: Implement multi-factor authentication where possible
5. Security Headers
// Example of implementing security headers in Express
import helmet from 'helmet';
app.use(helmet()); // Adds various security headers
app.use(helmet.contentSecurityPolicy()); // Configures Content Security Policy
app.use(helmet.xssFilter()); // Sets X-XSS-Protection header
Choosing the Right Authentication Pattern for Your TypeScript Project
The authentication pattern you choose can significantly impact your application's security, scalability, and user experience. Here are recommendations tailored for TypeScript projects:
Authentication Pattern | Best For | TypeScript Advantages |
---|---|---|
JWT | APIs, SPAs, microservices | Strong payload typing, interface-based validation |
OIDC | Enterprise apps, compliance requirements | Complex schema modeling, type-safe token handling |
Magic Links | Consumer apps, reduced friction | Secure token modeling, type-safe email templates |
OAuth 2.0 | Social login, platform integration | Clear protocol flow typing, error handling |
Session-Based | Monolithic apps, traditional web | Type-safe session management |
WebAuthn | High-security applications | Complex data structure handling |
TypeScript Authentication Best Practices
Beyond choosing the right pattern, here are some authentication best practices that leverage TypeScript's strengths:
-
Use Literal Types for Authorization Roles:
type UserRole = 'admin' | 'editor' | 'viewer'; // Better than string
-
Create Custom Type Guards:
function isAuthenticated(req: Request): req is AuthenticatedRequest { return req.user !== undefined; }
-
Leverage Utility Types:
// Prevent JWT payload tampering in type-safe way type JWTPayload = Readonly<{ sub: string; exp: number; iat: number; roles: ReadonlyArray<UserRole>; }>;
-
Never Trust External Input:
// Validate with zod or similar const userSchema = z.object({ email: z.string().email(), password: z.string().min(8) });
-
Use Environment Variables with Type Safety:
// In a config.ts file interface Config { jwtSecret: string; tokenExpiry: number; // ... } export const config: Config = { jwtSecret: process.env.JWT_SECRET || throwConfigError('JWT_SECRET'), tokenExpiry: parseInt(process.env.TOKEN_EXPIRY || '3600'), // ... }; function throwConfigError(variable: string): never { throw new Error(`Missing required config variable: ${variable}`); }
Authentication Libraries with Strong TypeScript Support
The following libraries provide excellent TypeScript support for authentication:
- jose - Comprehensive JWT implementation
- openid-client - Certified OIDC implementation
- zod - Runtime validation with static type inference
- passport-typescript - Type definitions for Passport.js
- @simplewebauthn/server - WebAuthn implementation
Parting Thoughts
Authentication isn't just about implementing a protocol—it's about using the type system to enforce security constraints and make impossible states impossible. This is where TypeScript truly shines.
While types can't prevent all security issues, they give us guardrails that catch many common mistakes before deployment. Combined with proper testing and security reviews, strongly-typed authentication can be both developer-friendly and highly secure.
Remember: The best authentication system is one that's appropriately complex for your needs—no more, no less. Start simple, add complexity as requirements dictate, and let the type system guide your implementation.
Happy authenticating!