DEV Community

Awaliyatul Hikmah
Awaliyatul Hikmah

Posted on

How to Secure Docusaurus for Internal Documentation using Vercel Middleware

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.

Top comments (0)