JWT authentication
Gate your docs behind your own login system. Enable JWT authentication in docs.json and sign short-lived tokens for per-user sessions.
JWT authentication requires a paid plan and a Jamdesk project connected to a Git repository. Configuration lives in docs.json, so it rides along with your normal build-and-deploy flow.
If your product already has its own login system, JWT authentication lets you gate your docs behind it instead of handing out a shared passphrase. Your backend signs a short-lived token when a signed-in user clicks through to the docs. Jamdesk verifies it once, mints a session, and the visitor browses normally from then on. Visitors never need a Jamdesk account or a shared password.
How this differs from password protection
Password protection gives every visitor the same shared passphrase, which works well for internal docs, staging previews, or a single partner audience. JWT authentication is per-user: each visitor's identity, session length, and page access come from a token your backend signs. Docs access can follow your existing customer accounts, plans, or roles instead of one shared secret.
The two modes are mutually exclusive: auth.password and auth.jwt cannot both be enabled at once. See Migrating from password protection below if you're switching from one to the other.
Setup steps
{
"$schema": "https://jamdesk.com/docs.json",
"name": "Acme Docs",
"theme": "jam",
"auth": {
"jwt": {
"enabled": true,
"loginUrl": "https://app.example.com/docs-login",
"public": ["/changelog/*"]
}
}
}loginUrl is required whenever enabled: true, and it must be an absolute https:// URL. Unauthenticated visitors are redirected here with ?redirect=<path> so your login flow knows where to send them back. public is optional: paths or globs (* for one segment, ** for any depth) that stay reachable without signing in.
Open Project Settings in the dashboard and find the JWT authentication card. Click Generate signing key.
Jamdesk creates an Ed25519 keypair, keeps only the public key, and shows you the private key exactly once. Copy it into your secret manager immediately. Jamdesk never stores or emails the private key and can't recover it if you lose it. If that happens, generate a new key (this invalidates the old one, so update your backend's signing key at the same time).
git add docs.json
git commit -m "Turn on JWT authentication"
git pushOnce the build publishes, the site gates every page. Requests without a valid session redirect to your loginUrl.
Integrate your login flow
When a signed-in user clicks through to your docs, your backend signs a JWT and redirects the browser to the docs site's callback URL with the token in the URL fragment (after the #). Fragments never reach your server logs or any reverse proxy, because browsers don't send them with the request.
The token must be signed with EdDSA (Ed25519, matching the key you generated in the dashboard), and its exp claim should be no more than about 10 seconds in the future. That's a handshake window, not a session length. The actual session length is controlled separately by the expiresAt field in the payload (see the payload reference below).
import { SignJWT, importPKCS8 } from "jose";
// Store this in your secret manager. It's the private key Jamdesk showed
// you once when you generated it in Project Settings.
const privateKey = await importPKCS8(process.env.JAMDESK_JWT_PRIVATE_KEY!, "EdDSA");
async function signDocsToken(user: { groups: string[] }) {
return new SignJWT({
host: "acme.jamdesk.app", // or your custom domain, e.g. "docs.example.com"
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7-day session
groups: user.groups,
apiPlaygroundInputs: {
header: { Authorization: `Bearer ${user.apiToken}` },
},
})
.setProtectedHeader({ alg: "EdDSA" })
.setExpirationTime("10s") // handshake window, not session length
.sign(privateKey);
}
// In your "open docs" route/button handler:
app.get("/docs-login", requireAuth, async (req, res) => {
const token = await signDocsToken(req.user);
const redirect = req.query.redirect ?? "/";
res.redirect(
`https://acme.jamdesk.app/_jd/auth/callback?redirect=${encodeURIComponent(
String(redirect)
)}#${token}`
);
});import time
import jwt # PyJWT >= 2.4, with the cryptography extra installed
with open("jamdesk_jwt_private_key.pem", "rb") as f:
PRIVATE_KEY = f.read()
def sign_docs_token(user):
payload = {
"host": "acme.jamdesk.app", # or your custom domain
"exp": int(time.time()) + 10, # handshake window, not session length
"expiresAt": int(time.time()) + 60 * 60 * 24 * 7, # 7-day session
"groups": user.groups,
"apiPlaygroundInputs": {
"header": {"Authorization": f"Bearer {user.api_token}"},
},
}
return jwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")
@app.route("/docs-login")
def docs_login():
token = sign_docs_token(current_user)
redirect_path = request.args.get("redirect", "/")
return redirect(
f"https://acme.jamdesk.app/_jd/auth/callback"
f"?redirect={quote(redirect_path)}#{token}"
)Sign the token server-side only. The private key must never reach a browser or a public repo. Anyone holding it can mint sessions for your docs site.
Redirect flow
- A visitor requests a protected page (say,
/quickstart) without a valid session. Jamdesk responds with a redirect to{loginUrl}?redirect=%2Fquickstart. - Your login flow authenticates the visitor (however you normally do that), signs a JWT, and redirects them to
https://<your-docs-host>/_jd/auth/callback?redirect=%2Fquickstart#<jwt>. - The callback page reads the token out of the fragment client-side and posts it to Jamdesk's token-exchange endpoint. Jamdesk verifies the signature and claims, and on success sets a signed session cookie.
- The browser is redirected to the original destination,
/quickstart, now with a valid session. Theredirectvalue is preserved end-to-end so visitors land exactly where they started.
If your backend can't determine a redirect value (someone bookmarked your login page directly, for example), omit it and Jamdesk falls back to /.
Public pages
Some pages should stay reachable without signing in, such as a status page or a public changelog. There are three ways to mark a page public, and they all merge into one allow-list:
Frontmatter, for one page at a time:
---
title: Changelog
public: true
---
Navigation groups, for a whole section:
{
"navigation": {
"groups": [
{ "group": "Changelog", "public": true, "pages": ["changelog"] }
]
}
}Explicit globs, under auth.jwt.public[]:
{
"auth": {
"jwt": {
"enabled": true,
"loginUrl": "https://app.example.com/docs-login",
"public": ["/changelog/*", "/status"]
}
}
}Group-based access
Some pages should only be visible to certain authenticated users, such as an admin runbook or an enterprise-only reference. Add groups to a page's frontmatter:
---
title: Admin API Keys
groups: ["admin"]
---
A visitor's session carries the groups array your backend put in the JWT payload. If a page declares groups and the visitor's session doesn't intersect with that list, they get a 404 rather than a 401 or an unlock screen. This is deliberate: a group-restricted page doesn't reveal its own existence to users outside the group.
Details that affect how you use groups:
- Group pages are excluded from the sitemap, search, AI chat, and MCP, even for users who are in the group. Exclusion from these discovery surfaces is a build-time decision, not a per-visitor one. A member of the
admingroup can still open/admin/api-keysdirectly (by URL or internal link), but it won't turn up in search results, chat answers, orllms.txt. If you need a restricted page to be findable by its own audience, link to it from another page that audience can already reach. - An empty
groups: []means no restriction at all, not "nobody can see this." To remove a page's group restriction, delete thegroupsfield entirely rather than setting it to an empty array. - To restrict a page to nobody, unpublish it. There's no
groupsvalue that means "nobody": group membership is additive, and any overlap grants access. - Localized copies inherit the base page's
groupsautomatically, unless the translation declares its owngroupsin its frontmatter. Translating a restricted page doesn't accidentally make the translation public. - Keep group names short. Groups travel inside the session cookie: up to 32 groups per session, 64 characters each. Exceeding either limit doesn't trim the list; Jamdesk rejects the entire token with a 401 and grants no session.
API playground pre-fill
If your docs have an API playground, you can pre-fill it for signed-in visitors so they don't have to paste in their own API key. Include apiPlaygroundInputs in your JWT payload:
{
"host": "acme.jamdesk.app",
"apiPlaygroundInputs": {
"header": { "Authorization": "Bearer sk_live_user_specific_token" },
"query": { "org_id": "acme-corp" },
"path": { "workspace_id": "ws_123" }
}
}
header.Authorizationpre-fills the playground's auth field. ABearerprefix is stripped automatically if present.queryandpathpre-fill any matching parameter names on the current endpoint.serverandcookiesections are not supported. Onlyheader,query, andpathare applied.- Pre-fill never overwrites a value the visitor has already typed into the playground.
Payload reference
| Field | Required | Description |
|---|---|---|
host | Yes | Must exactly match the request host (case-insensitive): your *.jamdesk.app subdomain or your custom domain. A token signed for one host is rejected on any other. |
expiresAt | No | Unix timestamp (seconds) for how long the resulting session should last. Capped at 30 days; defaults to 7 days if omitted. This is independent of the token's own short-lived exp claim. |
groups | No | Array of group names the session should carry, up to 32 entries of 64 characters each. Exceeding either limit rejects the whole token (401, no session) rather than truncating the list. |
apiPlaygroundInputs | No | Pre-fill values for the API playground. Serialized size is capped at 2KB. If it doesn't fit, it's dropped without an error and the session is still granted. |
Logout
Signed-in visitors get a Log out link in the docs header. It sends them to /_jd/auth/logout, which clears the session cookie and redirects to your loginUrl. You can also link to it directly from your own app if you want a "sign out of docs" link elsewhere. It's a plain GET request with no body or headers required.
Feature behavior under auth
| Feature | Behavior |
|---|---|
llms.txt / llms-full.txt / sitemap | Gated with the rest of the site: unreachable without a valid session, same as any other page. |
| Group-restricted pages | Excluded from all of the above artifacts, plus search and AI chat, regardless of the requesting session's groups (see Group-based access). |
robots.txt | Always public. Search engines can see that a docs site exists and is gated; they can't see its content. |
Troubleshooting
Rotation and revocation take effect within about 15 seconds, not instantly, because the edge gate briefly caches auth config to keep every page request fast. Rotate in the dashboard does invalidate every existing session; give it up to 15 seconds before treating a still-valid old session as a bug.
This means your latest signing key hasn't reached the runtime cache, usually because a temporary write failure interrupted key generation or rotation. Jamdesk re-attempts the sync automatically whenever you open the settings page; if the banner stays, click Retry sync on it. If it still won't clear after retrying, rotate the key from the same card.
Check the host claim against the exact host being requested. If your docs are reachable at both a custom domain (docs.example.com) and the underlying *.jamdesk.app subdomain, a token signed for one will be rejected on the other: host binding is exact and case-insensitive but not alias-aware. Sign tokens for whichever host you actually link to, or sign two variants if you link to both.
Jamdesk's callback route refuses to redirect back into itself: a redirect value pointing at /_jd/auth/callback (or the unlock-style page underneath it) is rewritten to / instead of being honored. If you're still seeing a loop, check that your login flow isn't itself redirecting to the docs loginUrl in a cycle (for example, a login page that immediately bounces back to /docs-login when it can't find a docs session). The docs side of the loop is guarded; the loop is almost always in the login flow.
This is a config_error and blocks the build. Pick one; see Migrating from password protection for the safe order of operations if you're switching.
Security note
apiPlaygroundInputs, including any Authorization value you put in it, is readable by JavaScript running on your docs site via the session-info endpoint that powers the playground pre-fill. Pre-fill is convenient, but it is not a place for high-privilege secrets.
Send per-user, least-privilege credentials scoped to what that visitor is allowed to do, never an org-wide admin key. Treat anything you put in apiPlaygroundInputs as visible to the person browsing the docs, because it is.
Migrating from password protection
Switching from a shared password to JWT authentication requires no downtime, and the site stays gated throughout. Do it in this order:
Do this first, while password protection is still active. Generating a key doesn't change what's gated; the password stays in force the whole time.
{
"auth": {
"password": { "enabled": false },
"jwt": { "enabled": true, "loginUrl": "https://app.example.com/docs-login" }
}
}Commit and push. The moment this build publishes, the gate flips atomically from password to JWT, with no window where the site is unprotected. Any existing password-unlocked sessions end at the flip; visitors authenticate through your login flow from then on.
Once you've confirmed the JWT flow works end-to-end, go back to Project Settings and clear the stored password. It's inert at this point (password mode is off in docs.json), but clearing it removes the stored hash entirely.
