← Back to DevBytes

Zod Authentication: JWT, Sessions, and OAuth Integration

Introduction to Zod Authentication

Authentication is one of the most security-critical parts of any application, and yet it is also one of the most error-prone. Developers frequently deal with untyped payloads from JWTs, session stores, and OAuth providers, leading to runtime crashes, security vulnerabilities, and brittle code. Zod, a TypeScript-first schema validation library, offers a clean solution by letting you define schemas for every authentication-related data structure and validate them at runtime.

In this tutorial, you will learn how to integrate Zod with three of the most common authentication strategies: JSON Web Tokens (JWT), server-side sessions, and OAuth providers. By the end, you will have a robust, type-safe authentication layer that catches malformed data before it ever reaches your business logic.

Why Zod Matters for Authentication

Authentication flows involve data crossing many boundaries: HTTP requests, token decoders, session stores, and third-party APIs. TypeScript alone cannot guarantee the shape of data coming from these external sources. A JWT's payload, for example, is just a base64-decoded object at runtime — TypeScript has no way to verify its contents match your expectations.

Zod bridges this gap. With Zod, you define a schema once, and you get both a TypeScript type (via z.infer) and a runtime validator. This means:

Setting Up Your Project

Before diving into specific authentication strategies, install Zod and a few supporting libraries. This tutorial assumes a Node.js environment with TypeScript.

npm install zod jose express cookie-parser
npm install -D typescript @types/express @types/cookie-parser

We use jose for JWT operations because it is modern, supports edge runtimes, and works well with Zod-validated payloads. express and cookie-parser will serve as our HTTP framework for the session and OAuth examples.

JWT Authentication with Zod

Defining the JWT Payload Schema

A JWT carries claims — pieces of information about the user and the token itself. Rather than trusting these claims blindly, define a Zod schema that enforces exactly which claims your application expects.

import { z } from "zod";

export const JwtPayloadSchema = z.object({
  sub: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "service"]),
  iat: z.number().int().nonnegative(),
  exp: z.number().int().nonnegative(),
  iss: z.string().default("my-app"),
});

export type JwtPayload = z.infer<typeof JwtPayloadSchema>;

This schema enforces that the sub claim is a valid UUID, email is a properly formatted email address, role is one of three allowed values, and both iat (issued at) and exp (expiration) are non-negative integers. Any token missing these fields or containing invalid values will fail validation.

Signing and Verifying Tokens

With the schema in place, create helper functions that sign and verify tokens while always validating the payload through Zod. This ensures that even if a token is cryptographically valid, its contents still conform to your application's expectations.

import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function signToken(payload: Omit<JwtPayload, "iat" | "exp">): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const fullPayload: JwtPayload = {
    ...payload,
    iat: now,
    exp: now + 60 * 60, // 1 hour
    iss: "my-app",
  };

  // Validate before signing to catch issues early
  const validated = JwtPayloadSchema.parse(fullPayload);

  return new SignJWT(validated)
    .setProtectedHeader({ alg: "HS256" })
    .sign(secret);
}

export async function verifyToken(token: string): Promise<JwtPayload> {
  const { payload } = await jwtVerify(token, secret);

  // Validate the decoded payload with Zod
  return JwtPayloadSchema.parse(payload);
}

Notice that verifyToken calls JwtPayloadSchema.parse() after jwtVerify. The cryptographic check confirms the token was not tampered with, while Zod confirms the payload's structure and values are valid for your application. This two-layer defense is critical.

Building an Express Middleware

To protect routes, create a middleware that extracts the token from the Authorization header, verifies it, and attaches the typed payload to the request object.

import { Request, Response, NextFunction } from "express";

declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload;
    }
  }
}

export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing or malformed Authorization header" });
  }

  const token = header.slice(7);
  try {
    req.user = await verifyToken(token);
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

Because verifyToken returns a JwtPayload, the req.user property is fully typed. Any downstream handler can safely access req.user.role or req.user.email without additional checks.

Role-Based Access Control

Zod makes role-based access control declarative. You can build a helper that checks whether the authenticated user has the required role.

export function requireRole(role: JwtPayload["role"]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
    if (req.user.role !== role) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }
    next();
  };
}

// Usage:
// app.delete("/users/:id", authMiddleware, requireRole("admin"), deleteUser);

Session-Based Authentication with Zod

Why Validate Sessions?

Session-based authentication stores user state on the server, typically in a database or cache like Redis. The client receives only a session ID in a cookie. While this is generally more secure than JWTs for web applications, the session data itself still needs validation. Data can become corrupted, migrations can leave stale fields, and deserialization bugs can introduce unexpected shapes.

Defining the Session Schema

Create a schema that represents the full session object stored server-side. Include fields like the user ID, creation timestamp, last activity, and any metadata you need.

export const SessionSchema = z.object({
  sessionId: z.string().uuid(),
  userId: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
  createdAt: z.number().int().positive(),
  lastActivity: z.number().int().positive(),
  ipAddress: z.string().ip().optional(),
  userAgent: z.string().max(512).optional(),
});

export type Session = z.infer<typeof SessionSchema>;

Session Store with Validation

Build a session store wrapper that validates data on both read and write operations. This prevents corrupted data from propagating through your application.

import { randomUUID } from "crypto";

// In-memory store for demonstration; use Redis in production
const store = new Map<string, Session>();

export async function createSession(
  userId: string,
  email: string,
  role: "admin" | "user",
  metadata?: { ipAddress?: string; userAgent?: string }
): Promise<Session> {
  const now = Date.now();
  const session: Session = {
    sessionId: randomUUID(),
    userId,
    email,
    role,
    createdAt: now,
    lastActivity: now,
    ...metadata,
  };

  const validated = SessionSchema.parse(session);
  store.set(validated.sessionId, validated);
  return validated;
}

export async function getSession(sessionId: string): Promise<Session | null> {
  const raw = store.get(sessionId);
  if (!raw) return null;

  // Re-validate on read to catch any corruption
  const result = SessionSchema.safeParse(raw);
  if (!result.success) {
    store.delete(sessionId);
    return null;
  }

  // Check for session timeout (e.g., 24 hours of inactivity)
  const maxAge = 24 * 60 * 60 * 1000;
  if (Date.now() - result.data.lastActivity > maxAge) {
    store.delete(sessionId);
    return null;
  }

  // Update last activity
  result.data.lastActivity = Date.now();
  store.set(sessionId, result.data);
  return result.data;
}

export async function destroySession(sessionId: string): Promise<void> {
  store.delete(sessionId);
}

Cookie-Based Session Middleware

Wire the session store into Express using cookies. The cookie contains only the opaque session ID; all sensitive data stays on the server.

import cookieParser from "cookie-parser";

const app = express();
app.use(cookieParser());

export async function sessionMiddleware(req: Request, res: Response, next: NextFunction) {
  const sessionId = req.cookies?.sessionId;
  if (!sessionId) {
    req.user = undefined;
    return next();
  }

  const session = await getSession(sessionId);
  if (!session) {
    res.clearCookie("sessionId");
    req.user = undefined;
    return next();
  }

  req.user = {
    sub: session.userId,
    email: session.email,
    role: session.role,
    iat: Math.floor(session.createdAt / 1000),
    exp: 0, // Sessions don't use exp; handled by store
    iss: "my-app",
  };

  next();
}

Notice how the session data is mapped into the same JwtPayload-compatible shape used by the JWT middleware. This allows your route handlers to work uniformly regardless of whether the user authenticated via JWT or session.

OAuth Integration with Zod

The Challenge of OAuth Payloads

OAuth providers like Google, GitHub, and Microsoft return user profile data in their own formats. These payloads are external, can change without notice, and often contain optional fields. Zod is invaluable here because it lets you define exactly which fields you require and how to transform provider-specific data into your internal user model.

Defining Provider Schemas

Create separate schemas for each OAuth provider you support. This makes it easy to add new providers later and isolates provider-specific quirks.

// Google OAuth user profile
export const GoogleProfileSchema = z.object({
  sub: z.string(),
  email: z.string().email(),
  email_verified: z.boolean(),
  name: z.string(),
  picture: z.string().url().optional(),
  locale: z.string().optional(),
});

// GitHub OAuth user profile
export const GitHubProfileSchema = z.object({
  id: z.number(),
  login: z.string(),
  email: z.string().email().nullable(),
  name: z.string().nullable(),
  avatar_url: z.string().url().optional(),
});

export type GoogleProfile = z.infer<typeof GoogleProfileSchema>;
export type GitHubProfile = z.infer<typeof GitHubProfileSchema>;

Normalizing Provider Data

Different providers use different field names and structures. Use Zod's transform feature to normalize provider data into a unified internal user representation.

export const InternalUserSchema = z.object({
  provider: z.enum(["google", "github"]),
  providerUserId: z.string(),
  email: z.string().email(),
  name: z.string(),
  avatarUrl: z.string().url().optional(),
});

export type InternalUser = z.infer<typeof InternalUserSchema>;

export function normalizeGoogleProfile(raw: unknown): InternalUser {
  const profile = GoogleProfileSchema.parse(raw);
  return InternalUserSchema.parse({
    provider: "google",
    providerUserId: profile.sub,
    email: profile.email,
    name: profile.name,
    avatarUrl: profile.picture,
  });
}

export function normalizeGitHubProfile(raw: unknown): InternalUser {
  const profile = GitHubProfileSchema.parse(raw);
  if (!profile.email) {
    throw new Error("GitHub profile is missing a public email");
  }
  return InternalUserSchema.parse({
    provider: "github",
    providerUserId: String(profile.id),
    email: profile.email,
    name: profile.name ?? profile.login,
    avatarUrl: profile.avatar_url,
  });
}

The normalizeGitHubProfile function demonstrates an important pattern: GitHub may return null for the email field if the user has set it to private. Zod's schema catches this, and the function throws a clear error that you can surface to the user with instructions to make their email public.

OAuth Callback Handler

Here is a complete OAuth callback handler that exchanges an authorization code for an access token, fetches the user profile, validates it with Zod, and issues a session or JWT.

interface OAuthConfig {
  tokenUrl: string;
  profileUrl: string;
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  provider: "google" | "github";
}

export async function handleOAuthCallback(code: string, config: OAuthConfig): Promise<InternalUser> {
  // Step 1: Exchange code for access token
  const tokenResponse = await fetch(config.tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code,
      redirect_uri: config.redirectUri,
      grant_type: "authorization_code",
    }),
  });

  if (!tokenResponse.ok) {
    throw new Error(`Token exchange failed: ${tokenResponse.status}`);
  }

  const tokenData = await tokenResponse.json();

  // Validate the token response
  const TokenResponseSchema = z.object({
    access_token: z.string(),
    token_type: z.string(),
    scope: z.string().optional(),
  });

  const tokens = TokenResponseSchema.parse(tokenData);

  // Step 2: Fetch user profile
  const profileResponse = await fetch(config.profileUrl, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!profileResponse.ok) {
    throw new Error(`Profile fetch failed: ${profileResponse.status}`);
  }

  const rawProfile = await profileResponse.json();

  // Step 3: Validate and normalize
  if (config.provider === "google") {
    return normalizeGoogleProfile(rawProfile);
  } else {
    return normalizeGitHubProfile(rawProfile);
  }
}

Wiring the OAuth Route

Connect the callback handler to an Express route. After obtaining the normalized user, either create a session or issue a JWT, then redirect the user back to your application.

app.get("/auth/callback/google", async (req, res) => {
  const code = req.query.code;
  if (typeof code !== "string") {
    return res.status(400).json({ error: "Missing authorization code" });
  }

  try {
    const user = await handleOAuthCallback(code, {
      tokenUrl: "https://oauth2.googleapis.com/token",
      profileUrl: "https://openidconnect.googleapis.com/v1/userinfo",
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      redirectUri: process.env.GOOGLE_REDIRECT_URI!,
      provider: "google",
    });

    // Upsert user in your database here...
    // const dbUser = await upsertUser(user);

    // Option A: Create a session
    const session = await createSession(user.providerUserId, user.email, "user", {
      ipAddress: req.ip,
      userAgent: req.get("User-Agent"),
    });
    res.cookie("sessionId", session.sessionId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 24 * 60 * 60 * 1000,
    });

    res.redirect("/dashboard");
  } catch (err) {
    console.error("OAuth error:", err);
    res.redirect("/login?error=oauth_failed");
  }
});

Best Practices

Use safeParse for User-Facing Errors

When validating data that originates from user input (like OAuth callbacks or login forms), use safeParse instead of parse. This returns a result object instead of throwing, allowing you to handle errors gracefully and return meaningful messages.

const result = GoogleProfileSchema.safeParse(rawProfile);
if (!result.success) {
  console.error("Validation failed:", result.error.flatten());
  return res.status(400).json({
    error: "Invalid profile data from OAuth provider",
    details: result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`),
  });
}
const profile = result.data;

Keep Secrets Out of Schemas

Never include secrets like API keys or passwords in your Zod schemas or in the data that flows through them. Schemas are for structural validation, not for managing credentials. Keep secret handling in dedicated modules with strict access controls.

Version Your Token Schemas

If your JWT payload structure evolves over time, include a version field and create schemas for each version. This lets you support legacy tokens during migration periods.

const JwtPayloadV1Schema = z.object({
  version: z.literal(1),
  sub: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
  iat: z.number(),
  exp: z.number(),
});

const JwtPayloadV2Schema = z.object({
  version: z.literal(2),
  sub: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "service"]),
  permissions: z.array(z.string()),
  iat: z.number(),
  exp: z.number(),
});

const JwtPayloadSchema = z.discriminatedUnion("version", [
  JwtPayloadV1Schema,
  JwtPayloadV2Schema,
]);

type JwtPayload = z.infer<typeof JwtPayloadSchema>;

The discriminatedUnion on the version field lets Zod efficiently determine which schema to apply, and TypeScript will narrow the type automatically based on the version.

Validate at Every Boundary

Every time data crosses a trust boundary — incoming HTTP request, decoded JWT, session store read, OAuth provider response — validate it. This defense-in-depth approach ensures that a bug or attack at one layer does not compromise the entire system.

Use Strict Modes

Zod objects allow extra keys by default. For security-sensitive schemas, use .strict() to reject payloads with unexpected fields. This prevents injection of malicious data that your application might inadvertently trust.

const StrictJwtPayloadSchema = z.object({
  sub: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "service"]),
  iat: z.number(),
  exp: z.number(),
}).strict(); // Throws on unknown keys

Log Validation Failures

Validation failures in authentication flows are often signs of attacks or bugs. Log them with enough context to investigate, but never log sensitive data like full tokens or passwords.

try {
  req.user = await verifyToken(token);
} catch (err) {
  if (err instanceof z.ZodError) {
    console.warn("JWT payload validation failed", {
      issues: err.issues,
      ip: req.ip,
      path: req.path,
    });
  }
  return res.status(401).json({ error: "Invalid token" });
}

Conclusion

Zod transforms authentication from a fragile collection of untyped assumptions into a robust, validated pipeline. By defining schemas for JWT payloads, session objects, and OAuth profiles, you gain compile-time type safety and runtime validation in a single declarative step. The patterns in this tutorial — two-layer JWT verification, session store validation, provider-specific OAuth normalization, and discriminated unions for schema versioning — give you a foundation that scales from small applications to large production systems. Authentication will always require careful thought, but with Zod, you can express that thought as code that both the compiler and the runtime enforce consistently.

— Ad —

Google AdSense will appear here after approval

← Back to all articles