Secure Docusaurus Architecture

When building internal documentation with Docusaurus, standard static hosting exposes your sensitive content to the public internet. This guide provides a complete, step-by-step tutorial on how to secure your Docusaurus site using a serverless approach with Vercel.

The Challenge

Static site generators like Docusaurus are great for performance, but they produce simple HTML/JS/CSS files. Unlike a dynamic CMS, they don't have a backend to check "Is this user logged in?" before serving a page.

We need a solution that:

  1. Protects all routes (not just client-side masking).
  2. Verifies identity using a secure, stateless method.
  3. Requires zero server management.

The Solution Architecture

We will implement a Two-Layer Security Model:

  1. Vercel Edge Middleware: The "Bouncer" at the door. It intercepts requests at the CDN edge.
  2. Serverless Functions: The "ID Card Issuer". Handles email OTP and issues secure cookies.

Step-by-Step Implementation

Prerequisites

  • A Docusaurus project.
  • A Vercel account.
  • An email provider API (we'll use Resend for this example).

Step 1: Install Dependencies

You'll need jose for lightweight JWT handling and resend for sending emails.

npm install jose resend
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Vercel Project

Create a vercel.json file in the root to ensure Vercel builds Docusaurus correctly and serves the output.

{
    "buildCommand": "npm run build",
    "outputDirectory": "build"
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure Environment Variables

In your Vercel project settings, add the following variables:

  • AUTH_SECRET: A long random string (e.g., generated via openssl rand -hex 32).
  • RESEND_API_KEY: Your API key from Resend.

Step 4: Create the Edge Middleware (middleware.ts)

Create a middleware.ts file in the root of your project. This file runs on Vercel's Edge Network for every request.

// middleware.ts
import { jwtVerify } from 'jose';

// Paths that don't require authentication - be SPECIFIC
const PUBLIC_PATHS = [
    '/login',
    '/api/auth/', // Only auth-related APIs are public
];

// Static assets that are safe to serve without auth
// .js is needed for Docusaurus to render login page
// EXCLUDED: .json, .html, .xml, .txt (could leak doc content via prefetch)
const PUBLIC_EXTENSIONS = [
    '.js',      // Framework scripts needed for login page
    '.css',     // Stylesheets
    '.ico', '.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif', // Images
    '.woff', '.woff2', '.ttf', '.eot', // Fonts
];

export const config = {
    // Match all paths (Vercel Edge Middleware)
    matcher: '/(.*)',
};

// Parse cookie using Web API (Vercel Edge compatible)
function getCookie(request: Request, name: string): string | null {
    const cookieHeader = request.headers.get('cookie');
    if (!cookieHeader) return null;

    const match = cookieHeader.match(new RegExp(`(^| )${name}=([^;]+)`));
    return match ? decodeURIComponent(match[2]) : null;
}

export default async function middleware(request: Request) {
    const url = new URL(request.url);
    const pathname = url.pathname;

    // 1. Allow specific public paths (login page and auth APIs only)
    if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
        return undefined; // Continue to origin
    }

    // 2. Allow static assets (js, css, images, fonts - NOT json/html)
    if (PUBLIC_EXTENSIONS.some(ext => pathname.endsWith(ext))) {
        return undefined; // Continue to origin
    }

    // 3. Get auth token from cookie
    const token = getCookie(request, 'site_auth');

    if (!token) {
        return Response.redirect(new URL('/login', request.url), 302);
    }

    // 4. Verify JWT signature (stateless)
    try {
        const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
        await jwtVerify(token, secret);
        return undefined; // Token valid, continue to origin
    } catch (error) {
        // Invalid or expired token - redirect to login
        return Response.redirect(new URL('/login', request.url), 302);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Create Authentication APIs

Create a specific folder structure for Vercel Functions: api/auth/.

A. Send OTP (api/auth/send-otp.js)

import { Resend } from 'resend';
import { SignJWT } from 'jose';

const ALLOWED_DOMAIN = '@yourcompany.com';
const ALLOWED_EMAILS = [
    'admin@yourcompany.com',
    'tester@gmail.com'
];
const OTP_EXPIRY_SECONDS = 600; // 10 minutes

export default async function handler(req, res) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed' });
    }

    try {
        const { email } = req.body;

        if (!email || typeof email !== 'string') {
            return res.status(400).json({ error: 'Email is required' });
        }

        const normalizedEmail = email.toLowerCase().trim();

        // Validate: must be allowed domain OR in whitelist
        const isAllowedDomain = normalizedEmail.endsWith(ALLOWED_DOMAIN);
        const isWhitelisted = ALLOWED_EMAILS.includes(normalizedEmail);

        if (!isAllowedDomain && !isWhitelisted) {
            return res.status(403).json({
                error: 'Email not authorized for access'
            });
        }

        // Generate 6-digit OTP
        const otp = Math.floor(100000 + Math.random() * 900000).toString();

        // Create a signed token containing the OTP (stateless verification)
        const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
        const otpToken = await new SignJWT({
            email: normalizedEmail,
            otp: otp,
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime(`${OTP_EXPIRY_SECONDS}s`)
            .sign(secret);

        // Send email via Resend
        const resend = new Resend(process.env.RESEND_API_KEY);

        await resend.emails.send({
            from: 'Docs Auth <noreply@docs.yourcompany.com>',
            to: normalizedEmail,
            subject: 'Your Access Code',
            html: `
        <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 20px;">
          <h1 style="color: #1a1a1a;">Documentation Access</h1>
          <p>Your access code is:</p>
          <div style="background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px;">
            <span style="font-size: 32px; font-weight: bold; letter-spacing: 4px;">${otp}</span>
          </div>
          <p style="color: #888; font-size: 14px;">Expires in 10 minutes.</p>
        </div>
      `,
        });

        return res.status(200).json({
            success: true,
            message: 'OTP sent successfully',
            otpToken: otpToken, // Send token back to client for stateless verification
        });

    } catch (error) {
        console.error('Send OTP error:', error);
        return res.status(500).json({
            error: 'Failed to send OTP. Please try again.'
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

B. Verify OTP & Set Cookie (api/auth/verify-otp.js)

import { SignJWT, jwtVerify } from 'jose';

const SESSION_DURATION = 365 * 24 * 60 * 60; // 1 year session

export default async function handler(req, res) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed' });
    }

    try {
        const { email, otp, otpToken } = req.body;

        if (!email || !otp || !otpToken) {
            return res.status(400).json({ error: 'Email, OTP, and token are required' });
        }

        const normalizedEmail = email.toLowerCase().trim();
        const secret = new TextEncoder().encode(process.env.AUTH_SECRET);

        // Verify the OTP token
        let payload;
        try {
            const result = await jwtVerify(otpToken, secret);
            payload = result.payload;
        } catch (err) {
            return res.status(400).json({ error: 'OTP expired. Please request a new code.' });
        }

        // Check if email matches
        if (payload.email !== normalizedEmail) {
            return res.status(400).json({ error: 'Email mismatch. Please request a new code.' });
        }

        // Check if OTP matches
        if (payload.otp !== otp.trim()) {
            return res.status(400).json({ error: 'Invalid OTP. Please try again.' });
        }

        // OTP valid - generate session JWT
        const sessionToken = await new SignJWT({ email: normalizedEmail })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime(`${SESSION_DURATION}s`)
            .sign(secret);

        // Set Secure HttpOnly Cookie
        res.setHeader('Set-Cookie', [
            `site_auth=${sessionToken}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_DURATION}`,
        ]);

        return res.status(200).json({
            success: true,
            message: 'Authentication successful'
        });

    } catch (error) {
        console.error('Verify OTP error:', error);
        return res.status(500).json({
            error: 'Verification failed. Please try again.'
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

C. Verify Session Endpoint (api/auth/verify.js)

This endpoint is Essential for the client-side to check if the user is authenticated. It reads the HttpOnly cookie using a helper function and verifying the JWT token.

import { jwtVerify } from 'jose';

// Helper to parse cookies from header
function getCookie(request, name) {
    const cookieHeader = request.headers.get('cookie');
    if (!cookieHeader) return null;

    const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
        const [key, value] = cookie.trim().split('=');
        acc[key] = value;
        return acc;
    }, {});

    return cookies[name] || null;
}

export default async function handler(req, res) {
    try {
        // Robust cookie parsing for Node.js environment
        const cookieHeader = req.headers.cookie || '';
        const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
            const [key, value] = cookie.trim().split('=');
            if (key && value) acc[key] = value;
            return acc;
        }, {});

        const token = cookies['site_auth'];

        if (!token) {
            return res.status(401).json({ error: 'Not authenticated' });
        }

        const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
        // Verify token signature and expiration
        const { payload } = await jwtVerify(token, secret);

        return res.status(200).json({
            authenticated: true,
            email: payload.email,
        });

    } catch (error) {
        return res.status(401).json({ error: 'Invalid or expired token' });
    }
}
Enter fullscreen mode Exit fullscreen mode

D. Logout Endpoint (api/auth/logout.js)

To log the user out, we simply clear the cookie by setting its Max-Age to 0.

export default async function handler(req, res) {
    // Clear the auth cookie
    res.setHeader('Set-Cookie', [
        'site_auth=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
    ]);

    // Redirect to login page
    res.writeHead(302, { Location: '/login' });
    res.end();
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Client-Side Integration

We need a wrapper to handle the UI state (redirecting if session is invalid).

A. Create src/components/AuthWrapper.js

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

export default function AuthWrapper({ children }) {
    const [isAuthenticated, setIsAuthenticated] = useState(false);

    useEffect(() => {
        // Skip check on login page
        if (typeof window !== 'undefined' && window.location.pathname === '/login') {
            setIsAuthenticated(true);
            return;
        }

        // Verify token with API (HttpOnly cookies are sent automatically)
        fetch('/api/auth/verify', {
            method: 'GET',
            credentials: 'include',
        })
            .then((res) => {
                if (res.ok) {
                    setIsAuthenticated(true);
                } else {
                    window.location.href = '/login';
                }
            })
            .catch(() => {
                window.location.href = '/login';
            });
    }, []);

    if (!isAuthenticated) return null; // Or a loading spinner
    return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

B. Wrap the App in src/theme/Root.js

import React from 'react';
import AuthWrapper from '@site/src/components/AuthWrapper';

export default function Root({ children }) {
    return <AuthWrapper>{children}</AuthWrapper>;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Create the Login Page (src/pages/login.js)

Here is a minimal React component to handle the login flow.

import React, { useState } from "react";

export default function Login() {
    const [step, setStep] = useState("email"); // 'email' or 'otp'
    const [email, setEmail] = useState("");
    const [otp, setOtp] = useState("");
    const [otpToken, setOtpToken] = useState("");
    const [message, setMessage] = useState("");

    const handleSendOtp = async (e) => {
        e.preventDefault();
        const res = await fetch("/api/auth/send-otp", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email }),
        });
        const data = await res.json();

        if (res.ok) {
            setOtpToken(data.otpToken);
            setStep("otp");
            setMessage("Code sent!");
        } else {
            setMessage(data.error);
        }
    };

    const handleVerifyOtp = async (e) => {
        e.preventDefault();
        const res = await fetch("/api/auth/verify-otp", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, otp, otpToken }),
        });

        if (res.ok) {
            window.location.href = "/";
        } else {
            const data = await res.json();
            setMessage(data.error);
        }
    };

    return (
        <div style={{ padding: "2rem", maxWidth: "400px", margin: "0 auto" }}>
            <h1>Login</h1>
            {message && <p style={{ color: "red" }}>{message}</p>}

            {step === "email" ? (
                <form onSubmit={handleSendOtp}>
                    <input
                        type="email"
                        placeholder="Enter your email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        required
                    />
                    <button type="submit">Send Code</button>
                </form>
            ) : (
                <form onSubmit={handleVerifyOtp}>
                    <p>Enter code sent to {email}</p>
                    <input
                        type="text"
                        placeholder="123456"
                        value={otp}
                        onChange={(e) => setOtp(e.target.value)}
                        required
                    />
                    <button type="submit">Verify</button>
                </form>
            )}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Summary

By combining Docusaurus (Content) + Vercel Middleware (Security) + Serverless Functions (Auth Logic), we created a robust internal documentation hub that is:

  • Secure: Protected at the network edge.
  • Fast: Static content served from CDN (after auth check).
  • Cost-Effective: Uses serverless pricing models.

This setup ensures your internal knowledge base remains internal, without the complexity of VPNs or enterprise SSO servers.