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
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
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.
Access is decided per page, and it is default-deny: with auth enabled, every page requires a reader session unless its frontmatter opens it up. Nothing is configured in the dashboard — the pages themselves carry the rules, so access lives in your repository and changes through the same review as any other content change.
| Frontmatter | Anonymous visitor | Signed-in reader |
|---|---|---|
public: true | Reads it | Reads it |
| (none) | Sent to sign-in | Reads it |
groups: ["admin"] | Sent to sign-in | Only if in a listed group — otherwise 404 |
public: true. It needs no session at all, so it stays
shareable and indexable. This is what lets one site carry public documentation and
internal pages.groups: ["admin"] — the reader must belong to at least one listed
group (groups come from the handshake’s groups claim). A reader outside every 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 pages makes the whole site
private.“Anonymous” and “signed in but in no groups” are not the same. A reader authenticated by
the password method carries no groups, yet may still read ungated pages; an anonymous
visitor may not. Treating them alike would make every page without a groups: line
world-readable the moment auth was switched on.
A page that isn’t public sends anonymous visitors to your sign-in rather than a 404 — signing in may genuinely grant access, so a dead end would be unhelpful. A signed-in reader who lacks the group gets the 404, because for them the answer really is “there is nothing here for you.”
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. A single page’s .md twin is the exception, because it’s a page
request rather than a bulk dump: it honors the reader’s session, so a signed-in reader gets
the Markdown for a page they can already read and an anonymous client gets the same 404 the
HTML page gives.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.