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, 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

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, 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.

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, 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, rotate the key. Rotation is a hard cutover, so 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

Once 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).

TypeScript (jose)
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}`
  );
});
Python (pyjwt)
import time
from urllib.parse import quote

import jwt  # PyJWT >= 2.4, with the cryptography extra installed
from flask import redirect, request  # or your framework's equivalents

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

  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, and on success 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 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:

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. The images and videos on it are served from your project's asset paths, which stay behind the gate, so a signed-out visitor sees a public page without its pictures. Add the asset paths to auth.jwt.public[] when a public page needs them:

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

Scope the glob to the folders your public pages actually use. Asset paths are never group-checked, so a broad glob such as /_jd/images/** serves every image on the site to anyone at all, 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

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 admin group can still open /admin/api-keys directly (by URL or internal link), but it won't turn up in search results, chat answers, or llms.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 the groups field entirely rather than setting it to an empty array.
  • To restrict a page to nobody, unpublish it. There's no groups value that means "nobody": group membership is additive, and any overlap grants access.
  • Localized copies inherit the base page's groups automatically, unless the translation declares its own groups in its frontmatter. Translating a restricted page doesn't accidentally make the translation public.
  • 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, and 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, so 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, and a group or tab that ends up empty is left out with it, so the name of a restricted section isn't shown to people 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

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.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. If it doesn't fit, it's dropped without an error and the session is still granted.

Rotating the signing key

Rotate signing key on the dashboard card generates a new keypair and shows the new private key once, the same way the first generation did. There is no overlap period. Within about 15 seconds the old key stops being accepted and every existing session ends. Until your backend signs with the new key, every sign-in is rejected and visitors bounce between your login page and the docs.

So the order matters:

  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, and tell whoever owns the backend deploy before you click.

If all you want is to end everyone's session, after a laptop went missing for example, use Revoke sessions instead. It keeps the key, so nothing in your backend changes; every visitor just has to sign in again.

Clear signing key removes the public key from Jamdesk. auth.jwt is still enabled in docs.json, so the site stays gated, but no token can be verified until you generate a new key. 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, 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.

Logging out of the docs doesn't log the visitor out of your product. If your login flow signs a token for anyone who already has an app session, a visitor who clicks Log out and then opens a docs link is signed straight back in. That's usually what you want. If you need a real sign-out, have your loginUrl handler check for an explicit sign-in rather than minting a token automatically, or point your own logout at the docs logout URL as well.

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.
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).
robots.txtAlways 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.

The dashboard and the runtime cache disagree about your signing key, usually because a temporary write failure 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 once the runtime is reachable.

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:

1
Generate the JWT signing key

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.

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 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.

3
Clear the password

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.

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.