JWT authentication
Learn how to enable JWT authentication in docs.json to gate documentation with your own login system and signed tokens.
JWT authentication requires a paid plan and a Jamdesk project connected to a Git repository. Configuration lives in docs.json, so it piggybacks on 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 — no separate Jamdesk account, no password to distribute.
How this differs from password protection
Password protection gives every visitor the same shared passphrase — good 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, so you can tie docs access to 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 — 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 it, never emails it, 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, since browsers don't send them in 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. This isn't a session length — it's a handshake window. 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), just omit it — Jamdesk falls back to /.
Public pages
Some pages should stay reachable without signing in — a status page, a public changelog, a marketing-adjacent landing page. 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 — an admin runbook, 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 — not a 401 or an unlock screen. This is deliberate: a group-restricted page doesn't reveal its own existence to users outside the group.
A few precision points worth knowing:
- 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 blanket 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 explicitly from another page that audience can already reach. - An empty
groups: []means no restriction at all — not "nobody can see this." If you want 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 (any overlap grants access), so an empty or nonsense group list is equivalent to no restriction. - Localized copies inherit the base page's
groupsautomatically, unless the translation declares its owngroupsin its frontmatter — so 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 — more than 32 groups, or any single name over 64 characters — doesn't trim the list; it rejects the entire token (401, no session granted). Keep your backend's group list within the cap rather than relying on truncation.
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 in this release — 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 silently dropped 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. Link to it directly from your own app if you want a "sign out of docs" affordance elsewhere too — it's a plain GET request, 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 — the edge gate caches auth config briefly to keep every page request fast. If you need every existing session invalidated the moment you click, that's exactly what Rotate does in the dashboard; give it up to 15 seconds before treating a still-valid old session as a bug.
This means the signing-key version stored in the dashboard doesn't match the version the live site is currently serving — usually because a key rotation happened but a rebuild hasn't run since, or a build partially failed. Click Rebuild now on the banner to force a full rebuild and re-sync the two.
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. This is the same trade-off Mintlify's playground pre-fill makes: convenient, but 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 doesn't require any downtime, and the site is never public at any point. Do it in this order:
Do this first, while password protection is still active. Generating a key doesn't change what's gated — it just gets you ready. 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 — there's 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.
