Reader auth
A standards-based handshake that gates a customer's published docs for their own end users, without Papervine ever storing reader credentials.
A standards-based handshake that gates a customer's published docs for their own end users, without Papervine ever storing reader credentials.
Reader auth (Layer 2) gates a customer’s published docs pages for their end users. The key property: Papervine never runs an identity provider for readers and never stores reader credentials. Instead, we verify a signed assertion produced by the customer’s own login system, then mint a short-lived docs session. The customer’s IdP does the hard part, so this is a much smaller security surface than real authentication.
The model matches the common “Authentication & Personalization” handshake, so an existing such configuration migrates unchanged. For how this differs from the dashboard account system, see platform auth and the auth overview.
A handshake is how a reader who is already logged into the customer’s app proves it to the docs site. Three methods are supported, in build order.
Each tenant gets an Ed25519 keypair and holds the private key. After a reader logs into
the customer’s own app, the customer’s backend signs a JWT (EdDSA only) and redirects
the browser to https://{DOCS_HOST}/login/jwt-callback#{JWT} — the token rides in the
URL hash, so it is never written to server logs. Papervine verifies the signature and
the host claim, then sets its own docs session cookie.
For tenants that already run an OAuth/OIDC server. They expose a user-info GET endpoint
that returns the same User JSON shape; Papervine runs the standard authorization-code +
PKCE flow and reads the reader’s groups and content from it.
A single shared secret, with no per-user identity. The cheapest method to ship and the simplest to configure.
The signed payload is compatible with the standard reader-auth schema:
type User = {
host?: string; // must equal the docs domain — blocks token replay
expiresAt?: number; // docs session length (hours to weeks)
groups?: string[]; // drives page access control
content?: Record<string, any>; // personalization, exposed as `user` in MDX
apiPlaygroundInputs?: { // pre-fills API keys in the playground
server; header; query; cookie; path;
};
};
Two expirations do different jobs. The JWT’s own exp is kept very short (≤10 seconds) —
it is a one-time handoff, not a session. The expiresAt field is the docs session
length and can run from hours to weeks. The host claim must equal the docs domain so a
token signed for one site can’t be replayed against another.
When an unauthenticated reader follows a deep link into a gated page, the flow round-trips
the intended path through ?redirect=%2Fintended-path so they land where they meant to after
the handshake completes.
In Settings → Authentication, enable authentication and choose JWT. Papervine generates an Ed25519 keypair for the site: it shows you the private key to copy into your backend and keeps the matching public key to verify tokens with. Set your Login URL — the page where your own app signs readers in. The keypair is stable: switching between auth methods leaves it untouched, so only the Regenerate button rotates it — and regenerating invalidates the old key, so update your backend whenever you do.
Then, in your login flow, sign a token with that private key and redirect back to the docs:
import * as jose from "jose";
const key = await jose.importPKCS8(PAPERVINE_PRIVATE_KEY, "EdDSA");
const token = await new jose.SignJWT({
host: "docs.example.com", // must equal the docs domain
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 1 week
Because verification needs only the public key — never a shared secret — a leak of Papervine’s stored config can’t forge reader tokens, and the same property lets the gate move to the edge in the future.
The password method needs no backend integration — it’s a single shared secret, so it’s the fastest way to gate a site.
In Settings → Authentication, enable authentication and choose Password. Enter a shared password (at least 8 characters) and save. The password is stored encrypted; the dashboard can reveal it again to the site’s own owner.
Distribute it however you like (a team channel, a welcome email). Everyone who should see the docs uses the same one.
When a reader hits a gated page, Papervine sends them to the site’s /login, where they enter
the password. On a correct, constant-time match it sets the same short-lived docs session
cookie the JWT method uses, and bounces them back to the page they wanted. The login form
renders on whichever host serves the site — a subdomain ({slug}.papervine.io/login), the
apex path form (/sites/{slug}/login), or a custom domain (docs.example.com/login).
The password method has no per-user identity — there’s one secret, not per-reader logins.
So it can’t carry groups or content, which means per-group access control and
personalization are only available with the JWT or OAuth methods. Rotating the password (save
a new one) immediately invalidates the old one for future sign-ins.
Gating is default-deny: an auth-enabled site requires a valid reader session, and on top of that you gate individual pages by group.
groups: ["admin"] in a page’s frontmatter — the reader must belong
to at least one listed group (groups come from the handshake’s groups claim). A reader
who isn’t in any listed group can’t reach the page and never sees it in the sidebar —
it’s dropped from the nav entirely, so its existence doesn’t leak. Because groups come from
the handshake, the password method (which has no per-user identity) can’t satisfy a
groups: page — group gating needs the JWT or OAuth method.public: true in its frontmatter — any reader who
cleared the site’s sign-in sees it, regardless of groups.A group or tab whose pages are all gated away from a reader disappears entirely — no empty heading, no teasing “Internal” tab. Access lives on the page; the sidebar and tabs are derived from what the reader can actually reach.
Denial returns 404, not 403 — on purpose. A 403 would confirm that a protected page exists at that URL; a 404 leaks nothing. The sidebar hides restricted pages for the same reason.
Enforcement happens at the node-level render chokepoint that serves tenant docs, before a
page renders. That point — rather than edge middleware — is required because the per-site
auth config is a database read the edge can’t do, the same constraint that governs
custom-domain resolution. A gated site renders only to a reader holding a valid, site-bound
docs session; otherwise it redirects to that site’s /login, carrying the intended path.
A page’s content can leave the renderer through three other doors — full-text search (Cmd-K), the AI assistant (which retrieves pages to ground its answers), and the generated MCP server (which exposes the docs as tools to external AI clients). All three honor the same per-page access as the sidebar, so a reader can never pull — or have the assistant cite — a page they couldn’t open directly:
llms.txt / llms-full.txt (the AI-discovery feed) is likewise anonymous, so a gated
site publishes only its public pages there — the full-corpus dump never includes gated bodies.On a site without reader auth, none of this changes anything: every page is public, so all of these behave exactly as before.
The content blob from the handshake is exposed as a user variable in MDX scope (threaded
through src/libathe third-party MDX serializer package.tsx), so a page can render conditionally per reader or per group — for
example, showing different content to an admin group.
Personalization forces per-request rendering, which works against compile-on-sync caching. It is therefore sequenced after the core handshake and gating, rather than shipped alongside them. See the auth overview for how the layers are sequenced.