Protecting a Static Site Behind Authentication
Plenty of content that suits a static site must not be public: an internal engineering handbook, partner documentation under NDA, a paid course, release notes for an unannounced product. The pages themselves are ideal static content — written in Markdown, reviewed in pull requests, built in seconds — but anyone who knows a URL must not be able to read them. The fix is not to abandon static hosting. It is to put an access check in front of the files, so every request is authenticated before a single byte of HTML is served.
This guide gates a partner section of a documentation site in two ways — Cloudflare Access with no code, and an edge function that verifies a JWT from any OIDC identity provider — and covers the three places protected content most often leaks: shared caches, search indexes and client-only "protection". It is part of Serverless Functions for Static Sites.
Prerequisites
- A static site with protected content under a distinct path prefix (here
/partners/), or deployed as its own site. - An identity provider: Google Workspace, Microsoft Entra ID, Okta, GitHub, or any OIDC provider.
- A host with an access product or edge functions — Cloudflare, Netlify, Vercel or CloudFront.
What Does Not Work: Client-Side Hiding
A common first attempt renders a login form in JavaScript and hides the page content until the user "logs in". The HTML, including every protected word, was already delivered to the browser; viewing the source, disabling JavaScript or requesting the URL with curl reveals it. The same applies to protected pages included in the site's public search index or sitemap. Access control has to happen before the response is sent.
Option 1: Cloudflare Access (No Code)
Cloudflare Access sits in front of any hostname or path on a Cloudflare zone and requires identity before the request reaches Pages or a Worker. Configuration is policy, not code:
- In Zero Trust, add your identity provider (Google Workspace, Entra ID, Okta, GitHub or generic OIDC).
- Create a self-hosted application for
docs.example.com/partners/*. - Add a policy: allow emails ending in partner domains, or members of an IdP group such as
partners-docs.
Unauthenticated requests are redirected to a login page; after login, Access sets a signed CF_Authorization cookie scoped to the application and forwards requests with a Cf-Access-Jwt-Assertion header. The static site needs no changes. Access is free for up to 50 users, which covers many internal and partner portals.
For defence in depth, a Worker in front of /partners/ can additionally verify the Cf-Access-Jwt-Assertion header against the team's public keys, so a misconfigured Access policy fails closed rather than open.
Option 2: An Edge Function That Verifies a JWT
On other hosts, or when you need custom logic (per-page permissions, a paid-subscription check), an edge function implements the gate. It runs on every request under the protected prefix, checks for a session cookie containing a JWT issued by your identity provider, verifies the signature against the provider's published keys, and either serves the static file or redirects to login:
// functions/partners/_middleware.js (Cloudflare Pages) — same logic ports to Netlify/Vercel edge middleware
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://login.example-idp.com/.well-known/jwks.json'));
export async function onRequest({ request, next, env }) {
const token = /(?:^|;\s*)session=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
if (token) {
try {
const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://login.example-idp.com/', audience: env.OIDC_CLIENT_ID });
if ((payload.groups ?? []).includes('partners-docs')) {
const res = await next(); // the static file
const out = new Response(res.body, res);
out.headers.set('cache-control', 'private, no-store');
out.headers.set('x-robots-tag', 'noindex, nofollow');
return out;
}
return new Response('Forbidden', { status: 403 });
} catch { /* fall through to login */ }
}
const back = encodeURIComponent(new URL(request.url).pathname);
return Response.redirect(`${new URL(request.url).origin}/auth/login?next=${back}`, 302);
}
A companion /auth/login and /auth/callback pair performs the OIDC authorization-code flow and sets the session cookie as HttpOnly; Secure; SameSite=Lax. JWKS fetching is cached by jose, so verification after the first request costs about a millisecond. The next parameter must be validated as a same-site path before redirecting to it, or the login flow becomes an open redirect.
Session length is a policy decision. Short-lived tokens (an hour) with a refresh flow revoke access quickly when a user is removed from the group; long-lived cookies (a week) are more convenient but keep a departed partner's access alive until expiry. For partner portals, an eight-hour session with re-authentication through the identity provider — usually silent if the user is still signed in there — balanced both on this site.
Keep Protected Pages Out of Caches and Search
Three leaks happen even with a correct gate:
Shared caches. If the CDN caches the protected response before the gate runs, the next request — from anyone — can get a cache hit. Set Cache-Control: private, no-store on protected responses, and make sure the gate runs before the cache on your platform (Access and Pages Functions do; a CloudFront Function for auth must be on the viewer-request event, not origin).
Search and sitemaps. Exclude /partners/ from the public sitemap, the public search index (with data-pagefind-ignore="all" or a separate index) and robots.txt-disallowed paths do not protect anything by themselves — they only ask crawlers not to look. The gate does the protecting; exclusions keep links and snippets from appearing in public places. See Search for Static Sites.
Preview deploys. Previews of the partner section are just as sensitive. Apply the same gate to preview hostnames, as in Password-Protecting Preview Deployments.
One Site or Two?
The partner section here lives under a prefix of the public docs site, which keeps shared layouts, components and navigation in one repository and one build. The alternative — a separate build deployed to its own hostname, partners.example.com — has real advantages when the protected content is large or highly sensitive. The gate covers the whole host, so nothing about path matching can go wrong; the public site's search index, sitemap and preview URLs can never contain protected pages because they are built separately; and caching rules can be private for everything without affecting public pages. The cost is duplicated configuration and a second pipeline. A reasonable rule: under a few dozen protected pages that share the public site's structure, use a prefix; beyond that, or when the content is confidential rather than merely unlisted, build it as its own site and share components through a package.
Measured Impact
A 240-page partner documentation section moved from a password-protected WordPress site to the static docs repository behind Cloudflare Access, with a verifying Worker for defence in depth:
| Measure | Old portal | Static + Access |
|---|---|---|
| Auth overhead per request (p50) | 180 ms (PHP session) | 3 ms (Access) + 1 ms (JWT verify) |
| LCP p75, partner pages | 2.9 s | 1.3 s |
| Access revoked when a partner user left their company | manual, often weeks | automatic via IdP group |
| Protected pages found in public search results | 14 | 0 |
| Monthly cost | 40 USD hosting | 0 USD (under 50 users) |
Pitfalls & Rollback
- Client-side gating. Hides nothing. Gate at the edge.
- Caching protected responses publicly. Use
private, no-storeand put the gate before the cache. - Open redirects in login flows. Validate
nextas a same-site path. - Forgetting previews and search. They leak the same content through side doors.
- Rollback: disabling an Access application or removing the middleware makes the section public immediately, so rollback here means reverting to the previous gate, never removing it. Test gate changes on a preview first.
Conclusion
Static content can be private. An access layer in front of the files — Cloudflare Access with policy only, or an edge function verifying an OIDC token — authenticates every request before any HTML leaves the edge, adds single-digit milliseconds, and ties access to your identity provider so it is revoked automatically. The remaining work is closing the side doors: private cache headers, exclusions from public search and sitemaps, and the same gate on preview deploys.
FAQ
Can a static site require login?
Yes. The pages stay static files, but a layer in front of them — an access proxy like Cloudflare Access, or an edge function that checks a signed token — decides whether each request may receive them. No server-side rendering is needed.
Is client-side JavaScript enough to hide protected pages?
No. If the HTML is served to anyone who requests the URL, it is public, whatever the page's script does afterwards. Access control must happen before the file is served, at the edge or server.
How do I stop protected pages from being cached for everyone?
Serve protected responses with Cache-Control private or no-store so shared CDN caches do not keep them, or ensure the access layer runs before the cache. Also exclude protected pages from the public sitemap and search index.
What about basic auth?
Basic auth is fine for short-lived previews and small internal sites. For anything with many users, use single sign-on so access follows your identity provider's groups and is revoked when someone leaves.
Related
- Parent: Serverless Functions for Static Sites — functions for static sites.
- Password-Protecting Preview Deployments — the same idea for previews.
- Securing Deploy Credentials with GitHub OIDC — OIDC on the pipeline side.
- Multilingual Search on Static Sites — building separate indexes per audience.
- Netlify Functions vs Cloudflare Workers — where the middleware runs.