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:
- Protects all routes (not just client-side masking).
- Verifies identity using a secure, stateless method.
- Requires zero server management.
The Solution Architecture
We will implement a Two-Layer Security Model:
- Vercel Edge Middleware: The "Bouncer" at the door. It intercepts requests at the CDN edge.
- 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
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"
}
Step 3: Configure Environment Variables
In your Vercel project settings, add the following variables:
-
AUTH_SECRET: A long random string (e.g., generated viaopenssl 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);
}
}
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.'
});
}
}
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.'
});
}
}
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' });
}
}
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();
}
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}</>;
}
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>;
}
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>
);
}
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)