← Back to DevBytes

TypeScript Compiler Authentication: JWT, Sessions, and OAuth Integration

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:

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

TypeScript Best Practices

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles