Skip to main content
The WorkOS Node SDK provides comprehensive session management capabilities for server-side applications, including sealed sessions, automatic refresh, and JWT validation.

Session Types

WorkOS supports two session management patterns:
  1. Stateless Sessions: Sealed (encrypted) session cookies
  2. Server-Side Sessions: Track active sessions via API

Sealed Sessions

Sealed sessions encrypt user data in a cookie, enabling stateless authentication without database lookups.

Initialization

import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS('sk_...');

// Generate a secure cookie password (32+ characters)
const COOKIE_PASSWORD = process.env.WORKOS_COOKIE_PASSWORD;
if (!COOKIE_PASSWORD || COOKIE_PASSWORD.length < 32) {
  throw new Error('COOKIE_PASSWORD must be at least 32 characters');
}
Cookie Password Security: Generate a cryptographically random password and store it securely. Rotating this password invalidates all existing sessions.

Creating Sealed Sessions

Enable session sealing during authentication:
const { sealedSession, user, accessToken, refreshToken } = 
  await workos.userManagement.authenticateWithCode({
    code: authorizationCode,
    session: {
      sealSession: true,
      cookiePassword: COOKIE_PASSWORD,
    },
  });

// Store in HTTP-only cookie
res.cookie('wos-session', sealedSession, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
});
Sealed sessions work with all authentication methods:
// Password authentication
const { sealedSession } = await workos.userManagement.authenticateWithPassword({
  email,
  password,
  session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
});

// Magic auth
const { sealedSession } = await workos.userManagement.authenticateWithMagicAuth({
  code: magicAuthCode,
  email,
  session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
});

// Refresh token
const { sealedSession } = await workos.userManagement.authenticateWithRefreshToken({
  refreshToken,
  session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
});

What’s Inside a Sealed Session?

Sealed sessions contain:
interface SessionCookieData {
  organizationId?: string;
  user: User;
  accessToken: string;
  refreshToken?: string;
  authenticationMethod: string;
  impersonator?: Impersonator;
}
This data is encrypted using iron (same library used by Next.js) with AES-256-CBC and HMAC-SHA256.

Loading and Authenticating Sessions

Basic Authentication

import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS('sk_...');

async function authenticateRequest(req: Request) {
  const sessionData = req.cookies['wos-session'];
  if (!sessionData) {
    return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' };
  }

  const session = workos.userManagement.loadSealedSession({
    sessionData,
    cookiePassword: COOKIE_PASSWORD,
  });

  return await session.authenticate();
}

Handling Authentication Results

The authenticate() method returns a discriminated union:
const result = await session.authenticate();

if (!result.authenticated) {
  // Handle failure
  switch (result.reason) {
    case 'NO_SESSION_COOKIE_PROVIDED':
      return res.redirect('/login');
    case 'INVALID_SESSION_COOKIE':
      // Cookie tampered or expired
      res.clearCookie('wos-session');
      return res.redirect('/login');
    case 'INVALID_JWT':
      // Access token expired - attempt refresh
      return await refreshSession(session);
  }
}

// Success - access user data
const { user, sessionId, organizationId, role, permissions } = result;
req.user = user;
req.organizationId = organizationId;

Session Middleware (Express)

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

function requireAuth() {
  return async (req: Request, res: Response, next: NextFunction) => {
    const sessionData = req.cookies['wos-session'];
    if (!sessionData) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    const session = workos.userManagement.loadSealedSession({
      sessionData,
      cookiePassword: COOKIE_PASSWORD,
    });

    const result = await session.authenticate();

    if (!result.authenticated) {
      if (result.reason === 'INVALID_JWT') {
        // Try to refresh
        const refreshed = await session.refresh();
        if (refreshed.authenticated) {
          // Update cookie with new session
          res.cookie('wos-session', refreshed.sealedSession, {
            httpOnly: true,
            secure: true,
            sameSite: 'lax',
            maxAge: 1000 * 60 * 60 * 24 * 7,
          });
          req.user = refreshed.user;
          req.organizationId = refreshed.organizationId;
          return next();
        }
      }
      
      res.clearCookie('wos-session');
      return res.status(401).json({ error: 'Session invalid' });
    }

    req.user = result.user;
    req.organizationId = result.organizationId;
    req.permissions = result.permissions;
    next();
  };
}

// Usage
app.get('/api/dashboard', requireAuth(), (req, res) => {
  res.json({ user: req.user });
});

Session Refresh

Sessions expire when the access token expires (typically 10 minutes). Use refresh() to get a new access token:
const refreshed = await session.refresh();

if (!refreshed.authenticated) {
  // Refresh failed - user must re-authenticate
  switch (refreshed.reason) {
    case 'INVALID_SESSION_COOKIE':
      // No refresh token in session
      return res.redirect('/login');
    case 'invalid_grant':
      // Refresh token expired or revoked
      res.clearCookie('wos-session');
      return res.redirect('/login');
    case 'mfa_enrollment':
      // User must enroll in MFA
      return res.redirect('/enroll-mfa');
    case 'sso_required':
      // SSO policy requires re-authentication
      return res.redirect('/login?sso_required=true');
  }
}

// Update cookie with new sealed session
res.cookie('wos-session', refreshed.sealedSession, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 1000 * 60 * 60 * 24 * 7,
});

Automatic Refresh Strategy

Implement proactive refresh before token expiration:
import { jwtDecode } from 'jwt-decode';

async function getSessionWithAutoRefresh(session: CookieSession) {
  const result = await session.authenticate();
  
  if (!result.authenticated) {
    if (result.reason === 'INVALID_JWT') {
      // Token expired, refresh
      return await session.refresh();
    }
    return result;
  }

  // Check if token expires soon (within 5 minutes)
  const decoded = jwtDecode(result.accessToken);
  const expiresAt = decoded.exp! * 1000;
  const fiveMinutes = 5 * 60 * 1000;
  
  if (Date.now() >= expiresAt - fiveMinutes) {
    // Proactively refresh
    return await session.refresh();
  }

  return result;
}

Organization Switching

Refresh to a different organization context:
const refreshed = await session.refresh({
  organizationId: 'org_456',
});

if (refreshed.authenticated) {
  // User now authenticated to org_456
  res.cookie('wos-session', refreshed.sealedSession, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });
}
Rotate encryption keys without invalidating sessions:
const NEW_COOKIE_PASSWORD = process.env.NEW_COOKIE_PASSWORD;

const refreshed = await session.refresh({
  cookiePassword: NEW_COOKIE_PASSWORD,
});

if (refreshed.authenticated) {
  // Session re-encrypted with new password
  res.cookie('wos-session', refreshed.sealedSession, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });
}

Logout

Properly log out users by revoking the session and clearing cookies:
async function logout(req: Request, res: Response) {
  const sessionData = req.cookies['wos-session'];
  
  if (sessionData) {
    const session = workos.userManagement.loadSealedSession({
      sessionData,
      cookiePassword: COOKIE_PASSWORD,
    });

    try {
      // Get logout URL (redirects user through WorkOS)
      const logoutUrl = await session.getLogoutUrl({
        returnTo: 'https://example.com',
      });
      
      res.clearCookie('wos-session');
      return res.redirect(logoutUrl);
    } catch (error) {
      // Session invalid, just clear cookie
      res.clearCookie('wos-session');
      return res.redirect('/');
    }
  }

  res.redirect('/');
}

Server-Side Session Management

Track and manage active user sessions:

List User Sessions

const sessions = await workos.userManagement.listSessions(userId);

for await (const session of sessions) {
  console.log({
    id: session.id,
    createdAt: session.createdAt,
    ipAddress: session.ipAddress,
    userAgent: session.userAgent,
  });
}

Revoke Sessions

Revoke specific sessions or all sessions except current:
// Revoke specific session
await workos.userManagement.revokeSession({
  sessionId: 'session_123',
});

// Revoke all sessions except current
await workos.userManagement.revokeSession({
  sessionId: currentSessionId,
  revokeAllSessions: true,
});

Session Activity Dashboard

async function getSessionActivity(userId: string) {
  const sessions = await workos.userManagement.listSessions(userId, {
    limit: 10,
    order: 'desc',
  });

  const activeSessions = [];
  for await (const session of sessions) {
    activeSessions.push({
      id: session.id,
      ipAddress: session.ipAddress,
      userAgent: session.userAgent,
      createdAt: session.createdAt,
      current: session.id === currentSessionId,
    });
  }

  return activeSessions;
}

Advanced Patterns

Multi-Tenant Session Context

Include organization ID in session for tenant isolation:
function requireOrganization(organizationId: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const result = await authenticateSession(req);
    
    if (!result.authenticated) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (result.organizationId !== organizationId) {
      return res.status(403).json({ 
        error: 'Not authorized for this organization',
      });
    }

    req.user = result.user;
    req.organizationId = result.organizationId;
    next();
  };
}

app.get(
  '/api/orgs/:orgId/data',
  requireOrganization('org_123'),
  (req, res) => {
    // User verified to belong to org_123
    res.json({ data: 'sensitive' });
  }
);

Role-Based Access Control

function requireRole(...allowedRoles: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const result = await authenticateSession(req);
    
    if (!result.authenticated) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!result.role || !allowedRoles.includes(result.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    req.user = result.user;
    req.role = result.role;
    next();
  };
}

app.delete('/api/users/:id', requireRole('admin', 'owner'), (req, res) => {
  // Only admins and owners can access
});

Permission Checks

function requirePermission(permission: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const result = await authenticateSession(req);
    
    if (!result.authenticated) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!result.permissions?.includes(permission)) {
      return res.status(403).json({ 
        error: `Missing permission: ${permission}`,
      });
    }

    req.user = result.user;
    req.permissions = result.permissions;
    next();
  };
}

app.post(
  '/api/data/export',
  requirePermission('data:export'),
  (req, res) => {
    // User has data:export permission
  }
);

Security Best Practices

1

Use HTTP-Only Cookies

Always set httpOnly: true to prevent XSS attacks from accessing the session cookie.
2

Enable Secure Flag in Production

Set secure: true to ensure cookies are only sent over HTTPS:
res.cookie('wos-session', sealedSession, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
});
3

Configure SameSite

Use sameSite: 'lax' for most apps, or strict for additional CSRF protection.
4

Set Appropriate Max-Age

Balance security and UX:
  • Short-lived sessions (30 min - 1 hour): High security apps
  • Medium-lived sessions (1-7 days): Most applications
  • Long-lived sessions (30+ days): Consumer apps
5

Clear Cookies on Logout

Always clear session cookies and revoke server-side sessions.
6

Implement Session Timeout

Track last activity and force re-authentication after inactivity:
const INACTIVITY_TIMEOUT = 30 * 60 * 1000; // 30 minutes

if (Date.now() - lastActivity > INACTIVITY_TIMEOUT) {
  res.clearCookie('wos-session');
  return res.redirect('/login?timeout=true');
}

Troubleshooting

Public clients (no API key) cannot seal sessions. Use public client mode with token storage instead.
Check cookie maxAge and ensure it’s set in milliseconds (not seconds):
maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days in milliseconds

API Reference

See the source code for implementation details:
  • CookieSession class - src/user-management/session.ts:21
  • authenticate() - src/user-management/session.ts:45
  • refresh() - src/user-management/session.ts:114
  • getLogoutUrl() - src/user-management/session.ts:204

Build docs developers (and LLMs) love