Jamdesk Documentation logo

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. It deploys with your normal builds.

JWT authentication gates your docs behind your product's own login instead of a shared passphrase. Your backend signs a short-lived token when a signed-in visitor clicks through to the docs. Jamdesk verifies it once and mints a session. The visitor then browses normally. 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. It works well for internal docs, staging previews, or one 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.

auth.password and auth.jwt cannot be enabled at the same time. See Migrating from password protection to switch from one to the other.

Setup steps

1
Enable auth.jwt in docs.json
docs.json
{
  "$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>. Your login flow uses it to send them back. public is optional: paths or globs (* for one segment, ** for any depth) that stay reachable without signing in.

2
Generate the signing key

Open Project Settings in the dashboard and find the JWT authentication card. Click Generate signing key.

Jamdesk creates an Ed25519 keypair and keeps only the public key. It shows you the private key exactly once. Copy it into your secret manager immediately. Jamdesk never stores or emails the private key. It can't recover a lost key. Rotate the key if you lose it. Rotation is a hard cutover. Read Rotating the signing key before you click.

3
Commit and rebuild
git add docs.json
git commit -m "Turn on JWT authentication"
git push

The published build gates every page. Requests without a valid session redirect to your loginUrl.

Integrate your login flow

Your backend signs a JWT when a signed-in visitor clicks through to your docs. It redirects the browser to the docs site's callback URL with the token in the URL fragment (after the #). Browsers don't send fragments with the request. They never reach your server logs or any reverse proxy.

Sign the token with EdDSA (Ed25519, matching the key you generated in the dashboard). Its exp claim should be no more than about 10 seconds in the future. That's a handshake window, not a session length. The session length is set 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[]; apiToken: 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}`
  );
});

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

  1. A visitor requests a protected page (say, /quickstart) without a valid session. Jamdesk responds with a redirect to {loginUrl}?redirect=%2Fquickstart.
  2. 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>.
  3. 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. On success, it sets a signed session cookie.
  4. The browser is redirected to the original destination, /quickstart, now with a valid session. The redirect value is preserved end to end. Visitors land where they started.

Omit redirect if your backend can't determine it (a bookmarked login page, for example). Jamdesk falls back to /.

Public pages

Keep pages such as a status page or a public changelog reachable without sign-in. Mark a page public in three ways. They merge into one allow-list:

Frontmatter, for one page at a time:

---
title: Changelog
public: true
---

Navigation groups, for a whole section:

docs.json
{
  "navigation": {
    "groups": [
      { "group": "Changelog", "public": true, "pages": ["changelog"] }
    ]
  }
}

Explicit globs, under auth.jwt.public[]:

docs.json
{
  "auth": {
    "jwt": {
      "enabled": true,
      "loginUrl": "https://app.example.com/docs-login",
      "public": ["/changelog/*", "/status"]
    }
  }
}

Marking a page public opens the page itself. Its images and videos are served from your project's asset paths. Those paths stay behind the gate. A signed-out visitor sees the public page without its pictures. Add the asset paths a public page needs to auth.jwt.public[]:

docs.json
{
  "auth": {
    "jwt": {
      "public": ["/changelog/*", "/status", "/_jd/images/changelog/**"]
    }
  }
}

Scope the glob to the folders your public pages use. Asset paths are never group-checked. A broad glob such as /_jd/images/** serves every image on the site to anyone, including the screenshots inside pages you restricted with groups. Keep the images for public pages in their own folder and open only that folder.

Group-based access

Limit a page such as an admin runbook or an enterprise-only reference to certain signed-in visitors. Add groups to its frontmatter:

---
title: Admin API Keys
groups: ["admin"]
---

A visitor's session carries the groups array your backend put in the JWT payload. A visitor whose session shares no group with a page's groups list gets a 404, not a 401 or an unlock screen. A group-restricted page doesn't reveal that it exists to visitors outside the group.

Details that affect how you use groups:

  • Group pages are excluded from the sitemap, search, AI chat, and MCP, even for visitors in the group. Exclusion from these discovery surfaces is a build-time decision, not a per-visitor one. A member of the admin group can still open /admin/api-keys directly (by URL or internal link). It won't turn up in search results, chat answers, or llms.txt. Link to a restricted page from a page its audience can already reach to make it findable.
  • An empty groups: [] means no restriction at all, not "nobody can see this." Remove a page's group restriction by deleting the groups field, not by setting it to an empty array.
  • Unpublish a page to hide it from everyone. No groups value means "nobody". Group membership is additive. Any overlap grants access.
  • Localized copies inherit the base page's groups automatically, unless the translation declares its own groups in its frontmatter.
  • Jamdesk works out which top-level folders are translations from navigation.languages, plus any top-level folder named after a language code (fr, it, cs, and so on) that has pages inside it. A folder that only shares a name with a language code, say an it folder holding IT runbooks, is treated as a translation too. Its pages inherit the groups of any root page at the same path. This can only restrict more, never less. Rename the folder if it gets in your way.
  • groups restricts pages, not the images, videos, and other files a page embeds. An asset that only a restricted page links to is still served to any signed-in visitor who asks for its URL, whatever groups their session carries. Asset URLs follow your repository's file paths. A name like images/admin/sso-config.png is easy to guess. Keep anything you don't want every signed-in reader to see out of the docs repository.
  • The sidebar, tabs, breadcrumbs, and previous/next links are filtered per visitor. A page the visitor's groups don't cover is left out. A group or tab that ends up empty is left out too. The name of a restricted section isn't shown to visitors outside it. This filtering happens at request time and is separate from the build-time exclusions above, which apply to everyone.
  • 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

Pre-fill the API playground for signed-in visitors. They skip pasting 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.Authorization pre-fills the playground's auth field. A Bearer prefix is stripped automatically if present.
  • query and path pre-fill any matching parameter names on the current endpoint.
  • server and cookie sections are not supported. Only header, query, and path are applied.
  • Pre-fill never overwrites a value the visitor has already typed into the playground.

Payload reference

FieldRequiredDescription
hostYesMust 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.
expiresAtNoUnix timestamp (seconds) for when the resulting session should expire. Capped at 30 days. Defaults to 7 days if omitted. This is independent of the token's own short-lived exp claim.
groupsNoArray 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.
apiPlaygroundInputsNoPre-fill values for the API playground. Serialized size is capped at 2KB. Oversized values are dropped without an error. The session is still granted.

Rotating the signing key

Click Rotate signing key on the dashboard card to generate a new keypair. The new private key is shown once, like the first one. There is no overlap period. The old key stops being accepted within about 15 seconds. Every existing session ends. Every sign-in is rejected until your backend signs with the new key. Visitors bounce between your login page and the docs.

Follow this order:

  1. Have a deploy ready that reads the signing key from your secret manager rather than from a hardcoded value.
  2. Click Rotate signing key and copy the new private key.
  3. Update the secret and deploy. Sign-ins work again as soon as your backend is using the new key.

Rotate at a quiet time if you can. Tell whoever owns the backend deploy before you click.

Use Revoke sessions instead to end every session without a new key, for example after a laptop goes missing. It keeps the key. Nothing in your backend changes. Every visitor signs in again.

Clear signing key removes the public key from Jamdesk. auth.jwt stays enabled in docs.json. The site locks. Visitors see a page saying the site is private instead of your login page. No token works until you generate a new key. The same applies if you turn on auth.jwt before generating a key. Project API keys still work for the Search API, the MCP server, .md URLs and the llms files. Clear it only when you're moving the site to another access mode or retiring it.

Logout

Signed-in visitors get a Log out link in the docs header. It sends them to /_jd/auth/logout. That route clears the session cookie and redirects to your loginUrl. Link to it from your own app for a "sign out of docs" link elsewhere. It's a plain GET request with no body or headers required.

Logging out of the docs doesn't log the visitor out of your product. Your login flow may sign a token for any visitor with an app session. A visitor who clicks Log out and opens a docs link is then signed straight back in. That's usually what you want. For a full sign-out, have your loginUrl handler require an explicit sign-in instead of minting a token automatically. Or point your own logout at the docs logout URL too.

Feature behavior under auth

FeatureBehavior
llms.txt / llms-full.txt / sitemapGated with the rest of the site: unreachable without a valid session, same as any other page. llms.txt and llms-full.txt also open with a project API key. See llms.txt.
Group-restricted pagesExcluded from all of the above artifacts, plus search and AI chat, regardless of the requesting session's groups (see Group-based access).
Search API (/_api/search)Not gated. It authenticates with your jd_live_ API key instead of the login cookie. Keep the key on your server. Group-restricted pages are never in results.
MCP server (/_mcp)Send a project API key as Authorization: Bearer <key>. Without one, the server answers with a JSON 401 instead of the login redirect. See MCP Server.
.md page URLsReturn 401 without a login. With a project API key they return the pages that appear in search. See llms.txt.
AI chatAvailable to signed-in visitors. If the session expires, the chat asks the visitor to reload the page and sign in again.
robots.txtAlways public. Search engines can see that a docs site exists and is gated. They can't see its content.

AI tools and API keys

Tools that can't sign in, such as an MCP client or a script that fetches llms.txt, can use a project API key instead of a session cookie. The key works for the MCP server, .md page URLs and the llms files. It never opens HTML pages or the AI chat.

Troubleshooting

Rotation and revocation take effect within about 15 seconds, not instantly. The edge gate briefly caches auth config to keep page requests 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.

The dashboard and the runtime cache disagree about your signing key. A temporary write failure usually interrupted a generate, rotate, or clear. The banner says which way: either the latest key hasn't reached the cache yet (tokens signed with it may be rejected), or a key you cleared is still cached (tokens signed with it are still accepted). Jamdesk checks again every time you open the settings page. If the banner stays, click Retry sync. If that keeps failing, rotate the key, or generate and clear again in the cleared case. A banner that says Jamdesk could not verify the state at all means the check itself failed. Retry when the runtime is reachable.

Check the host claim against the exact host being requested. Your docs may be reachable at a custom domain (docs.example.com) and the underlying *.jamdesk.app subdomain. A token signed for one is rejected on the other. host binding is exact and case-insensitive but not alias-aware. Sign tokens for the host you 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 /. If the loop continues, check that your login flow isn't 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 is guarded. The loop is almost always in the login flow.

This is a config_error. It blocks the build. Pick one. See Migrating from password protection for the safe order when switching.

Security note

JavaScript on your docs site can read apiPlaygroundInputs, including any Authorization value in it. The session-info endpoint behind the playground pre-fill exposes it. Never put high-privilege secrets there.

Send per-user, least-privilege credentials scoped to what that visitor is allowed to do, never an org-wide admin key. Anything in apiPlaygroundInputs is visible to the person browsing the docs.

Migrating from password protection

Switching from a shared password to JWT authentication needs no downtime. The site stays gated throughout. Follow this order:

1
Generate the JWT signing key

Do this while password protection is still active. Generating a key doesn't change what's gated. The password stays in force.

2
Flip docs.json and rebuild
docs.json
{
  "auth": {
    "password": { "enabled": false },
    "jwt": { "enabled": true, "loginUrl": "https://app.example.com/docs-login" }
  }
}

Commit and push. The gate flips atomically from password to JWT when this build publishes. The site is never unprotected. Existing password-unlocked sessions end at the flip. Visitors then sign in through your login flow.

3
Clear the password

Confirm the JWT flow works end to end. Then clear the stored password in Project Settings. It's inert at this point (password mode is off in docs.json). Clearing it removes the stored hash.

What's next?

Access Control overview

Compare JWT authentication against password protection, SSO, and the multi-project pattern.

Password Protection

The shared-passphrase alternative: simpler to set up, with no backend integration required.

SSO (Enterprise)

Identity-provider-driven sign-in for enterprise customers.

Custom Domains

Put your docs on your own domain before wiring up your login flow.