Implementing a Secure Mock Service Worker for Storybook to Consume JWT-Protected Node.js APIs


The component development pipeline for our micro-frontend architecture was grinding to a halt. We had a suite of isolated UI components developed in Storybook, and a collection of Node.js microservices providing data, each secured with a standard JWT-based authentication scheme. The disconnect was stark and painful. Any component that required data fetching—a user profile card, a dashboard widget, a product list—was fundamentally broken within the Storybook environment. The API calls would be fired off, only to be met with a cascade of 401 Unauthorized responses.

Our initial workaround was primitive: hard-coded JSON mocks passed as props. This approach was a ticking time bomb of technical debt. The static mocks quickly diverged from the actual API contracts, leading to components that worked perfectly in Storybook but failed spectacularly upon integration. We were shipping bugs rooted in this development environment mismatch. The cost of context-switching for developers, who had to constantly validate their work against a live environment, was becoming unacceptable.

A more robust solution was imperative. The core requirements were clear:

  1. The solution must replicate the production authentication flow (JWT Bearer tokens) within Storybook.
  2. Components must not be modified to accommodate the mock environment; the data-fetching logic (fetch, axios, etc.) should work as-is.
  3. The mocking layer must be capable of simulating different user roles and permissions defined in the JWT payload.
  4. The system must be isolated from live backend services to ensure a stable, deterministic, and fast development experience.

Disabling authentication in development environments was a non-starter. It introduces security vulnerabilities and, more importantly, it fails to model the real-world behavior of the application, particularly around role-based access control (RBAC). The path forward pointed towards intercepting network requests initiated by the components and responding with realistic, stateful data that respected a simulated authentication context. This led us to select Mock Service Worker (MSW) as the cornerstone of our new development infrastructure. MSW’s ability to intercept requests at the network level using a Service Worker API meant we could build our solution without intruding on the component’s implementation details.

The Architectural Blueprint: An Integrated Mocking Ecosystem

The final architecture consists of three tightly integrated parts, all orchestrated within the Storybook ecosystem:

  1. A Standalone Node.js Mock Authentication Service: A lightweight Express.js server whose sole purpose is to issue JWTs for predefined user personas (e.g., ‘admin’, ‘standard-user’, ‘guest’). This service mimics our production identity provider but runs locally, ensuring isolation.
  2. Mock Service Worker (MSW) Handlers: A set of request handlers that intercept all API calls originating from our components. These handlers are the “brains” of the operation; they validate the incoming mock JWT and return different data payloads based on the user’s claims.
  3. Storybook Integration Layer: A custom Storybook toolbar and a global decorator that act as the user interface for this system. Developers can select a user persona from the toolbar, which triggers a request to the mock auth service, stores the resulting JWT in Storybook’s global state, and re-renders the component within an authenticated context.

Here is the flow of control in this system:

sequenceDiagram
    participant Dev as Developer
    participant SB_Toolbar as Storybook Toolbar
    participant NodeMockAuth as Node.js Mock Auth Service
    participant SB_Global as Storybook Globals
    participant MSW as Mock Service Worker
    participant Component as React Component

    Dev->>SB_Toolbar: Selects 'Admin' persona
    SB_Toolbar->>NodeMockAuth: POST /login { username: 'admin' }
    NodeMockAuth-->>SB_Toolbar: Returns JWT for Admin
    SB_Toolbar->>SB_Global: Stores Admin JWT
    SB_Global-->>Component: Triggers re-render
    Component->>MSW: fetch('/api/users/1') with 'Authorization' header
    Note right of MSW: Intercepts network request
    MSW->>MSW: Decodes JWT from header
    Note right of MSW: Finds 'role: admin' in payload
    MSW-->>Component: Returns detailed user data (Admin view)

This design decouples the authentication simulation from the data mocking, allowing us to maintain a clean separation of concerns and build a system that closely mirrors our production environment’s logic.

Phase 1: The Node.js Mock Authentication Service

The foundation is a minimal yet robust Node.js server using Express and jsonwebtoken. Its responsibility is limited to issuing tokens for a static set of users. In a real-world project, this server must be kept simple to avoid it becoming another complex service to maintain.

File structure:

/mock-auth-server
  - package.json
  - server.js

package.json

{
  "name": "mock-auth-server",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "body-parser": "^1.20.2",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.2"
  }
}

server.js
This server exposes a single /login endpoint. It doesn’t need a real password check; the goal is simply to issue a token corresponding to a known user persona.

// mock-auth-server/server.js

const express = require('express');
const bodyParser = require('body-parser');
const jwt = a require('jsonwebtoken');
const cors = require('cors');

const app = express();
const PORT = 3010; // A dedicated port for the mock service

// A common mistake is to commit secrets. In a real project, this should come from environment variables.
// For this local-only mock server, defining it here is acceptable for simplicity.
const JWT_SECRET = 'a-super-secret-key-for-dev-only';

// Our stable of predefined user personas for development.
const users = {
  admin: {
    id: 'user-1',
    name: 'Admin User',
    roles: ['admin', 'editor', 'viewer'],
    permissions: ['read:users', 'write:users', 'delete:users']
  },
  editor: {
    id: 'user-2',
    name: 'Editor User',
    roles: ['editor', 'viewer'],
    permissions: ['read:users', 'write:users']
  },
  viewer: {
    id: 'user-3',
    name: 'Viewer User',
    roles: ['viewer'],
    permissions: ['read:users']
  }
};

app.use(cors()); // Allow requests from the Storybook origin
app.use(bodyParser.json());

// Logger middleware for visibility into mock auth requests.
app.use((req, res, next) => {
  console.log(`[MockAuth] ${new Date().toISOString()} - ${req.method} ${req.originalUrl}`);
  next();
});

app.post('/login', (req, res) => {
  const { username } = req.body;

  if (!username || !users[username]) {
    console.error(`[MockAuth] Login failed: User '${username}' not found.`);
    return res.status(404).json({ message: 'User persona not found' });
  }

  const userProfile = users[username];
  
  // The payload contains all the necessary data for authorization decisions in the mock layer.
  const payload = {
    sub: userProfile.id,
    name: userProfile.name,
    roles: userProfile.roles,
    permissions: userProfile.permissions,
    iat: Math.floor(Date.now() / 1000)
  };

  const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });

  console.log(`[MockAuth] Issued token for user: ${username}`);
  res.status(200).json({ token });
});

// A simple health check endpoint.
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'OK' });
});


// Basic error handling
app.use((err, req, res, next) => {
  console.error('[MockAuth] An unexpected error occurred:', err.stack);
  res.status(500).json({ message: 'Internal Server Error' });
});

app.listen(PORT, () => {
  console.log(`[MockAuth] Mock Authentication Server running on http://localhost:${PORT}`);
});

To run this, we add a script to our main project’s package.json to start it alongside Storybook, perhaps using a tool like concurrently.

Phase 2: Integrating MSW and the Storybook Control Layer

With the token issuer ready, the next step is to configure Storybook to use it. This involves setting up MSW, creating a custom toolbar for persona selection, and a decorator to provide the authentication context to our stories.

1. MSW Setup

First, install MSW and its Storybook addon:
npm install msw msw-storybook-addon --save-dev

Then, initialize MSW in the project root:
npx msw init public/ --save

This creates a mockServiceWorker.js file in the public directory, which is crucial for enabling network interception in the browser.

In .storybook/preview.js, we initialize the addon:

// .storybook/preview.js

import { initialize, mswDecorator } from 'msw-storybook-addon';

// Initialize MSW
initialize();

export const decorators = [mswDecorator];

// ... rest of preview.js config

2. Storybook Globals and Toolbar

We define globals in .storybook/preview.js to hold the authentication state.

// .storybook/preview.js

// ... imports

initialize();

export const decorators = [mswDecorator];

export const globalTypes = {
  // This defines the custom toolbar dropdown
  userPersona: {
    name: 'User Persona',
    description: 'Simulate different user roles',
    defaultValue: 'guest',
    toolbar: {
      icon: 'user',
      items: [
        { value: 'guest', title: 'Guest (No Auth)' },
        { value: 'viewer', title: 'Viewer' },
        { value: 'editor', title: 'Editor' },
        { value: 'admin', title: 'Admin' },
      ],
      showName: true,
    },
  },
  // This global will store the actual token
  authToken: {
    name: 'Auth Token',
    description: 'Internal state for JWT',
    defaultValue: null,
    toolbar: {
      // Hide this from the toolbar as it's managed internally
      hidden: true,
    },
  },
};

3. The Authentication Decorator

This is the glue. We create a decorator that watches for changes in the userPersona global. When it changes, it fetches a new JWT from our mock auth server and stores it in the authToken global. This authToken is then accessible to our MSW handlers.

// .storybook/AuthDecorator.js

import { useEffect, useState } from 'react';

// This custom hook manages the token fetching logic.
const useAuthToken = (persona, updateGlobals) => {
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchToken = async () => {
      if (persona === 'guest') {
        updateGlobals({ authToken: null });
        return;
      }

      setIsLoading(true);
      try {
        const response = await fetch('http://localhost:3010/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username: persona }),
        });

        if (!response.ok) {
          throw new Error(`Failed to fetch token for ${persona}: ${response.statusText}`);
        }

        const { token } = await response.json();
        // Update the global state so MSW can access it
        updateGlobals({ authToken: token });
      } catch (error) {
        console.error("AuthDecorator Error:", error);
        // On failure, revert to unauthenticated state
        updateGlobals({ authToken: null });
      } finally {
        setIsLoading(false);
      }
    };

    fetchToken();
  }, [persona, updateGlobals]);

  return { isLoading };
};

export const AuthDecorator = (Story, context) => {
  const { userPersona } = context.globals;
  const { updateGlobals } = context;

  const { isLoading } = useAuthToken(userPersona, updateGlobals);

  if (isLoading) {
    return <div>Authenticating as {userPersona}...</div>;
  }

  return <Story {...context} />;
};

Now, we add this decorator to .storybook/preview.js:

// .storybook/preview.js

import { initialize, mswDecorator } from 'msw-storybook-addon';
import { AuthDecorator } from './AuthDecorator'; // Import our new decorator

initialize();

export const decorators = [
  mswDecorator,
  AuthDecorator // Add our decorator
];

// ... globalTypes definition ...

Phase 3: Crafting Role-Aware MSW Handlers

The final piece is to make our MSW handlers aware of the authentication context. The handlers will read the Authorization header from the intercepted request, validate the token (using the same shared secret as the mock auth server), and return a response tailored to the user’s permissions.

We’ll use jose for robust JWT parsing, as it’s a modern, dependency-free library. npm install jose --save-dev.

// src/mocks/handlers.js

import { rest } from 'msw';
import * as jose from 'jose';

// This MUST match the secret in the mock-auth-server.
const JWT_SECRET = 'a-super-secret-key-for-dev-only';
const secretKey = new TextEncoder().encode(JWT_SECRET);

// A helper function to verify the token from the request headers.
// A common pitfall is to forget proper error handling here.
// The function must gracefully handle missing tokens, malformed tokens, and invalid signatures.
async function verifyAuth(req) {
  const authHeader = req.headers.get('Authorization');

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return { error: 'Missing or malformed Authorization header', status: 401 };
  }

  const token = authHeader.split(' ')[1];

  try {
    const { payload } = await jose.jwtVerify(token, secretKey);
    return { payload };
  } catch (err) {
    console.error('[MSW Auth] Token verification failed:', err.message);
    // Differentiate between expired and invalid tokens if needed.
    return { error: 'Invalid or expired token', status: 403 };
  }
}

// Mock data
const usersData = {
  'user-1': { id: 'user-1', name: 'Admin User', email: '[email protected]', lastLogin: new Date().toISOString() },
  'user-2': { id: 'user-2', name: 'Editor User', email: '[email protected]' },
  'user-3': { id: 'user-3', name: 'Viewer User', email: '[email protected]' },
};

export const handlers = [
  // A public endpoint that doesn't require authentication.
  rest.get('/api/status', (req, res, ctx) => {
    return res(ctx.status(200), ctx.json({ status: 'API is running' }));
  }),

  // A protected endpoint that returns user data.
  rest.get('/api/user/profile', async (req, res, ctx) => {
    const auth = await verifyAuth(req);

    if (auth.error) {
      return res(ctx.status(auth.status), ctx.json({ message: auth.error }));
    }

    // The user's ID is in the 'sub' claim of the JWT payload.
    const userId = auth.payload.sub;
    const userProfile = usersData[userId];

    if (!userProfile) {
      return res(ctx.status(404), ctx.json({ message: 'User profile not found' }));
    }

    return res(ctx.status(200), ctx.json(userProfile));
  }),

  // An admin-only endpoint.
  rest.delete('/api/users/:userId', async (req, res, ctx) => {
    const auth = await verifyAuth(req);

    if (auth.error) {
      return res(ctx.status(auth.status), ctx.json({ message: auth.error }));
    }

    // Check for specific permissions from the JWT payload.
    if (!auth.payload.permissions?.includes('delete:users')) {
      return res(ctx.status(403), ctx.json({ message: 'Forbidden: You do not have permission to delete users.' }));
    }

    const { userId } = req.params;
    console.log(`[MSW] Admin user '${auth.payload.name}' deleting user '${userId}'.`);
    
    // In a real scenario, you might manipulate a mock database state here.
    return res(ctx.status(204));
  }),
];

We then import these handlers into .storybook/preview.js:

// .storybook/preview.js
// ... other imports
import { handlers } from '../src/mocks/handlers';

// ... other config
export const parameters = {
  msw: {
    handlers: handlers,
  },
};

The Result: A Fully Functional and Secure Component Story

Now we can write a story for a UserProfile component that showcases the entire system. The component itself is simple; it fetches data from /api/user/profile.

// src/components/UserProfile.jsx

import React, { useState, useEffect } from 'react';

export const UserProfile = ({ authToken }) => {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchProfile = async () => {
      setLoading(true);
      setError(null);
      setUser(null);

      if (!authToken) {
        setError('No authentication token provided.');
        setLoading(false);
        return;
      }

      try {
        const response = await fetch('/api/user/profile', {
          headers: {
            'Authorization': `Bearer ${authToken}`,
          },
        });

        if (!response.ok) {
          throw new Error(`API responded with ${response.status}: ${response.statusText}`);
        }
        const data = await response.json();
        setUser(data);
      } catch (e) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    };

    fetchProfile();
  }, [authToken]);

  if (loading) return <div>Loading profile...</div>;
  if (error) return <div style={{ color: 'red' }}>Error: {error}</div>;
  if (!user) return <div>No user data.</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>ID: {user.id}</p>
      <p>Email: {user.email}</p>
      {user.lastLogin && <p>Last Login (Admin Only): {user.lastLogin}</p>}
    </div>
  );
};

And the story that uses our global authToken:

// src/stories/UserProfile.stories.jsx

import React from 'react';
import { UserProfile } from '../components/UserProfile';

export default {
  title: 'Components/UserProfile',
  component: UserProfile,
};

const Template = (args, { globals }) => {
  // We grab the authToken directly from Storybook's global context.
  // The AuthDecorator ensures this is always up-to-date.
  const { authToken } = globals;
  return <UserProfile {...args} authToken={authToken} />;
};

export const Default = Template.bind({});
Default.args = {};

// We don't need separate stories for each user type anymore.
// The developer can simply use the toolbar to switch between personas,
// and the component will react accordingly. This significantly
// reduces story duplication and maintenance.

With this setup, a developer can open Storybook, select the “Admin” persona from the toolbar, and see the UserProfile component render the full details, including the lastLogin field. Switching to “Viewer” will re-render the component with the viewer’s data, which lacks the lastLogin field, perfectly simulating the API’s RBAC logic. Selecting “Guest” immediately shows the “No authentication token provided” error state. This provides an immediate, high-fidelity feedback loop that was previously impossible.

Limitations and Future Directions

This implementation provides a powerful and realistic development environment, but it’s not without its own set of trade-offs and potential future enhancements. The mock server’s logic and data structures can drift from the production services over time. To combat this, a contract testing framework like Pact could be integrated. The mock server would act as a provider in the Pact tests, ensuring its responses conform to the contracts established by the consumer components.

Furthermore, the current JWT handling logic does not account for token expiration and refresh flows. A more advanced iteration could involve setting shorter expiry times on the mock tokens and extending the MSW handlers to simulate the token refresh endpoint (/api/refresh-token). The AuthDecorator could be enhanced to manage this refresh cycle, providing an even more accurate simulation of the production authentication lifecycle. This would allow for testing edge cases where a component makes an API call with an expired token and must handle the refresh logic correctly.


  TOC