Introduction to TypeScript Authentication
Authentication is one of the most critical aspects of modern web applications. When building applications with TypeScript, leveraging the type system to enforce secure authentication flows can prevent common vulnerabilities and reduce runtime errors. This tutorial covers three primary authentication strategies—JWT (JSON Web Tokens), session-based authentication, and OAuth integration—and demonstrates how to implement each with strong typing and compiler-level safety.
What Is TypeScript Compiler Authentication?
TypeScript compiler authentication refers to the practice of using TypeScript's static type system to model, validate, and enforce authentication logic at compile time. Rather than relying solely on runtime checks, you define types and interfaces that represent authenticated states, user identities, token payloads, and session data. The compiler then catches mismatches before the code ever runs.
This approach combines traditional authentication mechanisms with type-driven development, ensuring that tokens are properly decoded, sessions are correctly typed, and OAuth flows handle all possible response states.
Why Authentication Type Safety Matters
Authentication bugs are among the most dangerous security issues in web applications. A missing null check on a decoded token, an untyped payload that gets passed to a privileged function, or an OAuth callback that fails to validate state—any of these can lead to unauthorized access. TypeScript helps mitigate these risks in several ways:
- Compile-time validation: The compiler enforces that authentication functions receive and return the correct types.
- Exhaustive checking: Union types and discriminated unions ensure you handle every possible authentication state.
- Refactoring safety: When you change a token structure or session shape, the compiler flags every affected location.
- Self-documenting code: Types serve as living documentation for your authentication contracts.
- Reduced runtime errors: Many null reference and property access errors are eliminated before deployment.
Setting Up the Project
Before diving into implementation, set up a TypeScript project with the necessary dependencies. This tutorial uses Express as the web framework, but the patterns apply to any Node.js framework.
mkdir ts-auth-tutorial
cd ts-auth-tutorial
npm init -y
npm install express jsonwebtoken cookie-parser bcryptjs
npm install -D typescript @types/express @types/jsonwebtoken @types/cookie-parser @types/bcryptjs ts-node
npx tsc --init
Configure your tsconfig.json with strict mode enabled to get the maximum benefit from type checking:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strictNullChecks": true
},
"include": ["src/**/*"]
}
JWT Authentication with TypeScript
JSON Web Tokens are a stateless authentication mechanism. The server issues a signed token containing claims about the user, and the client sends this token with each subsequent request. Let's build a fully typed JWT implementation.
Defining Token Types
Start by defining the types that represent your JWT payload and authentication result. Strong typing here ensures that every part of your application knows exactly what data the token contains.
// src/types/auth.ts
export interface JwtPayload {
userId: string;
email: string;
role: UserRole;
iat?: number;
exp?: number;
}
export type UserRole = 'admin' | 'user' | 'guest';
export interface AuthenticatedRequest {
user: JwtPayload;
token: string;
}
export type AuthResult =
| { success: true; payload: JwtPayload }
| { success: false; error: string };
export interface LoginCredentials {
email: string;
password: string;
}
Notice the AuthResult type uses a discriminated union. The success property acts as a discriminant, so when you check result.success, TypeScript narrows the type automatically and gives you access to either payload or error without any casting.
Creating and Signing Tokens
Next, implement the token creation logic. The function accepts a typed payload and returns a signed token string.
// src/auth/jwt.service.ts
import jwt, { SignOptions } from 'jsonwebtoken';
import { JwtPayload, UserRole } from '../types/auth';
const JWT_SECRET: string = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const JWT_EXPIRES_IN = '24h';
export function createToken(payload: Omit<JwtPayload, 'iat' | 'exp'>): string {
const signOptions: SignOptions = {
expiresIn: JWT_EXPIRES_IN,
algorithm: 'HS256',
};
return jwt.sign(payload, JWT_SECRET, signOptions);
}
export function verifyToken(token: string): JwtPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
return decoded;
} catch (err) {
return null;
}
}
export function refreshToken(token: string): string | null {
const payload = verifyToken(token);
if (!payload) return null;
const { iat, exp, ...rest } = payload;
return createToken(rest);
}
The Omit<JwtPayload, 'iat' | 'exp'> utility type ensures callers cannot manually set the issued-at or expiration fields—those are managed by the JWT library itself.
Building the JWT Middleware
Now create an Express middleware that extracts and validates the token from incoming requests. The middleware augments the Express Request type to include the authenticated user.
// src/auth/jwt.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from './jwt.service';
import { JwtPayload } from '../types/auth';
// Augment Express Request to include the user property
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticateJwt(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = authHeader.split(' ')[1];
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
req.user = payload;
next();
}
export function requireRole(role: UserRole): (req: Request, res: Response, next: NextFunction) => void {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.user.role !== role) {
res.status(403).json({ error: `Requires ${role} role` });
return;
}
next();
};
}
The global declaration merging at the top extends Express's Request interface so that req.user is typed as JwtPayload | undefined throughout your application. The requireRole function is a higher-order middleware factory that returns role-specific guards.
Using JWT in Routes
With the middleware in place, protect your routes by applying the authentication guard:
// src/routes/protected.routes.ts
import { Router } from 'express';
import { authenticateJwt, requireRole } from '../auth/jwt.middleware';
import { createToken } from '../auth/jwt.service';
import { LoginCredentials, UserRole } from '../types/auth';
const router = Router();
// Public login route
router.post('/login', (req, res) => {
const credentials: LoginCredentials = req.body;
// In production, verify against your database
if (credentials.email === 'admin@example.com' && credentials.password === 'password') {
const token = createToken({
userId: '1',
email: credentials.email,
role: 'admin' as UserRole,
});
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Protected route - requires authentication
router.get('/profile', authenticateJwt, (req, res) => {
// req.user is guaranteed to exist here because of the middleware
res.json({ user: req.user });
});
// Admin-only route
router.delete('/users/:id', authenticateJwt, requireRole('admin'), (req, res) => {
res.json({ message: `User ${req.params.id} deleted by ${req.user?.email}` });
});
export default router;
Session-Based Authentication
Unlike JWT, session-based authentication stores state on the server. The client receives a session ID (typically in a cookie), and the server looks up the session data on each request. This approach offers easier revocation but requires server-side storage.
Defining Session Types
Begin by modeling the session data structure and the session store interface:
// src/types/session.ts
export interface SessionData {
userId: string;
email: string;
role: UserRole;
createdAt: number;
lastAccessedAt: number;
}
export interface SessionStore {
get(sessionId: string): Promise<SessionData | null>;
set(sessionId: string, data: SessionData, ttl: number): Promise<void>;
destroy(sessionId: string): Promise<void>;
destroyAllForUser(userId: string): Promise<void>;
}
export type SessionResult =
| { valid: true; session: SessionData }
| { valid: false; reason: string };
Implementing an In-Memory Session Store
For this tutorial, implement a simple in-memory store. In production, you would use Redis or a database. The key point is that the store implements the SessionStore interface, so swapping implementations requires no changes to consuming code.
// src/auth/session.store.ts
import { SessionData, SessionStore } from '../types/session';
export class MemorySessionStore implements SessionStore {
private sessions: Map<string, { data: SessionData; expiresAt: number }> = new Map();
async get(sessionId: string): Promise<SessionData | null> {
const entry = this.sessions.get(sessionId);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.sessions.delete(sessionId);
return null;
}
entry.data.lastAccessedAt = Date.now();
return entry.data;
}
async set(sessionId: string, data: SessionData, ttl: number): Promise<void> {
this.sessions.set(sessionId, {
data,
expiresAt: Date.now() + ttl * 1000,
});
}
async destroy(sessionId: string): Promise<void> {
this.sessions.delete(sessionId);
}
async destroyAllForUser(userId: string): Promise<void> {
for (const [sessionId, entry] of this.sessions) {
if (entry.data.userId === userId) {
this.sessions.delete(sessionId);
}
}
}
}
Session Middleware
Create middleware that reads the session cookie, validates the session, and attaches the session data to the request:
// src/auth/session.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { SessionStore } from '../types/session';
import { MemorySessionStore } from './session.store';
import crypto from 'crypto';
const SESSION_COOKIE = 'sessionId';
const SESSION_TTL = 3600; // 1 hour in seconds
const store: SessionStore = new MemorySessionStore();
declare global {
namespace Express {
interface Request {
session?: import('../types/session').SessionData;
sessionId?: string;
}
}
}
export function generateSessionId(): string {
return crypto.randomBytes(32).toString('hex');
}
export async function createSession(
res: Response,
data: Omit<import('../types/session').SessionData, 'createdAt' | 'lastAccessedAt'>
): Promise<void> {
const sessionId = generateSessionId();
const now = Date.now();
const sessionData = {
...data,
createdAt: now,
lastAccessedAt: now,
};
await store.set(sessionId, sessionData, SESSION_TTL);
res.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: SESSION_TTL * 1000,
});
}
export async function authenticateSession(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const sessionId = req.cookies?.[SESSION_COOKIE];
if (!sessionId) {
res.status(401).json({ error: 'No session found' });
return;
}
const session = await store.get(sessionId);
if (!session) {
res.clearCookie(SESSION_COOKIE);
res.status(401).json({ error: 'Session expired or invalid' });
return;
}
req.session = session;
req.sessionId = sessionId;
next();
}
export async function destroySession(req: Request, res: Response): Promise<void> {
if (req.sessionId) {
await store.destroy(req.sessionId);
}
res.clearCookie(SESSION_COOKIE);
}
Session Routes Example
// src/routes/session.routes.ts
import { Router } from 'express';
import { authenticateSession, createSession, destroySession } from '../auth/session.middleware';
const router = Router();
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (email === 'user@example.com' && password === 'password') {
await createSession(res, {
userId: '2',
email,
role: 'user',
});
res.json({ message: 'Logged in successfully' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
router.get('/profile', authenticateSession, (req, res) => {
res.json({ session: req.session });
});
router.post('/logout', authenticateSession, async (req, res) => {
await destroySession(req, res);
res.json({ message: 'Logged out successfully' });
});
export default router;
OAuth Integration
OAuth 2.0 enables third-party authentication, allowing users to log in with providers like Google, GitHub, or Microsoft. Implementing OAuth in TypeScript requires careful handling of the authorization code flow, token exchange, and user profile retrieval.
Defining OAuth Types
// src/types/oauth.ts
export interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
authorizationUrl: string;
tokenUrl: string;
userInfoUrl: string;
redirectUri: string;
scopes: string[];
}
export interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope: string;
}
export interface OAuthUserProfile {
provider: string;
providerUserId: string;
email: string;
name: string;
avatar?: string;
}
export type OAuthState =
| { status: 'idle' }
| { status: 'authorizing'; authUrl: string }
| { status: 'success'; user: OAuthUserProfile; token: OAuthTokenResponse }
| { status: 'error'; message: string };
The OAuthState discriminated union models every possible state in the OAuth flow. This ensures your code handles success, error, and in-progress states exhaustively.
Implementing the OAuth Service
// src/auth/oauth.service.ts
import crypto from 'crypto';
import { OAuthProviderConfig, OAuthTokenResponse, OAuthUserProfile } from '../types/oauth';
export class OAuthService {
private config: OAuthProviderConfig;
private providerName: string;
private stateStore: Map<string, { createdAt: number }> = new Map();
constructor(providerName: string, config: OAuthProviderConfig) {
this.providerName = providerName;
this.config = config;
}
generateAuthUrl(): { url: string; state: string } {
const state = crypto.randomBytes(16).toString('hex');
this.stateStore.set(state, { createdAt: Date.now() });
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: this.config.scopes.join(' '),
state,
});
return {
url: `${this.config.authorizationUrl}?${params.toString()}`,
state,
};
}
validateState(state: string): boolean {
const entry = this.stateStore.get(state);
if (!entry) return false;
// State is valid for 10 minutes
if (Date.now() - entry.createdAt > 10 * 60 * 1000) {
this.stateStore.delete(state);
return false;
}
this.stateStore.delete(state);
return true;
}
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
});
const response = await fetch(this.config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
return response.json() as Promise<OAuthTokenResponse>;
}
async getUserProfile(accessToken: string): Promise<OAuthUserProfile> {
const response = await fetch(this.config.userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error('Failed to fetch user profile');
}
const raw = await response.json() as Record<string, unknown>;
return {
provider: this.providerName,
providerUserId: String(raw.id || raw.sub || ''),
email: String(raw.email || ''),
name: String(raw.name || raw.login || ''),
avatar: raw.avatar_url ? String(raw.avatar_url) : raw.picture ? String(raw.picture) : undefined,
};
}
async handleCallback(code: string, state: string): Promise<OAuthUserProfile> {
if (!this.validateState(state)) {
throw new Error('Invalid or expired OAuth state');
}
const tokenResponse = await this.exchangeCodeForToken(code);
const profile = await this.getUserProfile(tokenResponse.access_token);
return profile;
}
}
Configuring a GitHub OAuth Provider
// src/auth/oauth.config.ts
import { OAuthService } from './oauth.service';
import { OAuthProviderConfig } from '../types/oauth';
const githubConfig: OAuthProviderConfig = {
clientId: process.env.GITHUB_CLIENT_ID || '',
clientSecret: process.env.GITHUB_CLIENT_SECRET || '',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
redirectUri: 'http://localhost:3000/auth/github/callback',
scopes: ['read:user', 'user:email'],
};
export const githubOAuth = new OAuthService('github', githubConfig);
OAuth Routes
// src/routes/oauth.routes.ts
import { Router } from 'express';
import { githubOAuth } from '../auth/oauth.config';
import { createToken } from '../auth/jwt.service';
import { OAuthUserProfile } from '../types/oauth';
const router = Router();
// Step 1: Redirect user to GitHub for authorization
router.get('/github', (req, res) => {
const { url } = githubOAuth.generateAuthUrl();
res.redirect(url);
});
// Step 2: GitHub redirects back with authorization code
router.get('/github/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
res.status(400).json({ error: `OAuth error: ${error}` });
return;
}
if (!code || !state || typeof code !== 'string' || typeof state !== 'string') {
res.status(400).json({ error: 'Missing code or state parameter' });
return;
}
try {
const profile: OAuthUserProfile = await githubOAuth.handleCallback(code, state);
// In production, find or create user in your database
// Then issue your own JWT for subsequent API calls
const jwtToken = createToken({
userId: profile.providerUserId,
email: profile.email,
role: 'user',
});
// Redirect to frontend with token
res.redirect(`http://localhost:5173/auth/callback?token=${jwtToken}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `OAuth callback failed: ${message}` });
}
});
export default router;
Combining Authentication Strategies
In real applications, you often need to support multiple authentication methods simultaneously. TypeScript makes it straightforward to create a unified authentication middleware that checks for JWT tokens, session cookies, or OAuth-issued tokens in sequence.
// src/auth/unified.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from './jwt.service';
import { MemorySessionStore } from './session.store';
const sessionStore = new MemorySessionStore();
export async function authenticateAny(req: Request, res: Response, next: NextFunction): Promise<void> {
// Try JWT first
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
const payload = verifyToken(token);
if (payload) {
req.user = payload;
next();
return;
}
}
// Try session cookie
const sessionId = req.cookies?.['sessionId'];
if (sessionId) {
const session = await sessionStore.get(sessionId);
if (session) {
req.user = {
userId: session.userId,
email: session.email,
role: session.role,
};
req.session = session;
next();
return;
}
}
res.status(401).json({ error: 'No valid authentication found' });
}
Best Practices
Security Best Practices
- Never store secrets in source code. Use environment variables or a secrets manager for JWT secrets, OAuth client secrets, and session encryption keys.
- Use short token expiration times. JWT access tokens should expire within 15–60 minutes. Use refresh tokens for longer sessions.
- Always validate OAuth state parameters. This prevents CSRF attacks during the OAuth callback flow.
- Set secure cookie flags. Use
httpOnly,secure, andsameSiteattributes on session cookies. - Hash passwords with bcrypt. Never store plaintext passwords. Use a work factor of at least 12.
- Implement rate limiting on auth endpoints. Login and token refresh endpoints are prime targets for brute-force attacks.
TypeScript Best Practices
- Use discriminated unions for auth results. This forces exhaustive handling of success and failure cases.
- Avoid
anyin auth code. If you must handle unknown data, useunknownand narrow with type guards. - Use
strictNullChecks. This catches missing user or session references at compile time. - Augment Express types globally. Declare
req.userandreq.sessiononce so all routes benefit from typing. - Use
OmitandPickutility types. These prevent callers from setting internal fields likeiatorexpon JWT payloads. - Type your environment variables. Create a typed config loader so missing secrets are caught at startup, not at runtime.
Typed Environment Configuration
Here is a pattern for loading and validating environment variables with full type safety:
// src/config/env.ts
interface EnvConfig {
JWT_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
NODE_ENV: 'development' | 'production' | 'test';
PORT: number;
}
function loadEnv(): EnvConfig {
const required: Array<keyof EnvConfig> = ['JWT_SECRET', 'GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
return {
JWT_SECRET: process.env.JWT_SECRET!,
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID!,
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET!,
NODE_ENV: (process.env.NODE_ENV as EnvConfig['NODE_ENV']) || 'development',
PORT: parseInt(process.env.PORT || '3000', 10),
};
}
export const env = loadEnv();
Putting It All Together
Finally, wire everything together in your main application file:
// src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { env } from './config/env';
import protectedRoutes from './routes/protected.routes';
import sessionRoutes from './routes/session.routes';
import oauthRoutes from './routes/oauth.routes';
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/auth', oauthRoutes);
app.use('/session', sessionRoutes);
app.use('/api', protectedRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok', environment: env.NODE_ENV });
});
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
export default app;
Conclusion
TypeScript's type system provides a powerful layer of defense for authentication code. By modeling JWT payloads, session data, and OAuth flows with precise types and discriminated unions, you catch entire categories of bugs at compile time rather than at runtime. Whether you choose stateless JWT tokens, server-side sessions, or third-party OAuth integration—or a combination of all three—the patterns in this tutorial give you a foundation for building secure, maintainable authentication systems. Remember that type safety complements but does not replace runtime validation, secure secret management, and adherence to security best practices. Always validate input at trust boundaries, keep dependencies updated, and test your authentication flows thoroughly. With these tools and patterns in hand, you are well-equipped to implement robust authentication in your TypeScript applications.