Skip to Content
SDKAuthentication

Authentication

Before the SDK can load or post comments it needs a user session. You choose how that session is established by whether you pass onTokenNeeded to MarkUpSDK.init():

  • Bring your own token (onTokenNeeded) — authenticate your end users with your own backend. Use this when your app already knows who the user is.
  • Sign in with MarkUp (omit onTokenNeeded) — let the SDK sign users into their own MarkUp accounts.

When render() runs, the SDK resolves a session in this order:

Session resolution order
onTokenNeeded → cached session → silent SSO → interactive sign-in

If you provide onTokenNeeded, it wins. Otherwise the SDK reuses a cached session, then tries a silent single-sign-on, and finally falls back to an interactive Sign in button.


Mode A — Bring your own token

This is the right mode when your application has already authenticated the user and you want their MarkUp comments attributed to that same identity.

How it works

  1. The SDK calls your onTokenNeeded callback.
  2. Your backend signs a short-lived JWT with the private key for your SDK installation (RS256).
  3. onTokenNeeded returns that JWT to the SDK.
  4. The SDK exchanges it via POST /api/v2/auth/exchange for a short-lived MarkUp session token, and uses that for subsequent API calls.
onTokenNeeded
const markup = MarkUpSDK.init({ publicKey: "<YOUR_PUBLIC_KEY>", markupId: "<YOUR_MARKUP_ID>", onTokenNeeded: async () => { // Call your own backend, which signs and returns a JWT. const res = await fetch("/api/markup-token"); const {token} = await res.json(); return token; } });

JWT claims

Your backend must sign a JWT with these claims. Signatures are verified with RS256 against the public key registered on your installation — symmetric (HS256) signing is rejected.

OptionType
iss string

Issuer. Must match the expectedIss returned by GET /api/v2/auth/config.

aud string

Audience. Must match the expectedAud returned by GET /api/v2/auth/config.

sub string

Subject — a stable identifier for the end user in your system. Becomes the external user identity in MarkUp.

exp number

Expiration time, in Unix seconds. Keep it short-lived (e.g. a few minutes).

iat number

Issued-at time, in Unix seconds.

jti string

Unique token ID (nonce). Used for replay protection — generate a fresh value per token.

name optionalstring

Display name shown in the toolbar and on the user's comments.

email optionalstring

User email.

avatarUrl optionalstring

URL of the user's avatar image.

markupRole optionalstring

Feedback role for this user: project:viewer, project:contributor or project:owner. Defaults to project:contributor when omitted; any other value is rejected with 400. See Feedback role.

markupId optionalstring

Restrict the session to a single MarkUp. Omit it and the session can reach every MarkUp in your workspace. See Scoping a session to one MarkUp.

Feedback role

The markupRole claim is the only input to what your end user can do in MarkUp. Your backend asserts it, and a MarkUp account that happens to share the token’s email never changes it — so a viewer stays a viewer even if someone with the same address administers the workspace.

markupRoleThe user can
project:viewerRead the project, its threads and its comments — a read-only embed. No commenting.
project:contributorEverything a viewer can, plus post comments and resolve threads.
project:ownerEverything a contributor can, plus delete threads and comments, and — on plans that include it — pause or resume commenting.

Omit the claim and the user gets project:contributor. Any other value — a role that does not exist, or a non-string — is rejected: POST /api/v2/auth/exchange returns 400, no exchange token is minted, and the request creates no MarkUp user. There is no silent fallback, so a typo fails loudly instead of resolving to a role you did not intend.

Scoping a session to one MarkUp

By default a session reaches every MarkUp in your workspace — the token is scoped to your installation’s workspace and nothing narrower. If your app already knows which MarkUp a user should see, sign a markupId claim and MarkUp will enforce it.

Scoped token
{ // ...the usual claims markupId: "b2a1f0c4-8f3e-4a91-9d77-1c5e6a0b2d34" }

A session minted from that token can only act on that MarkUp. Any request addressed at another one — its comments, replies, attachments, tag data, view modes, read-only toggle, or the MarkUp itself — is rejected with 403, and the user gets no role on it.

The claim is validated when the token is exchanged. A MarkUp that doesn’t exist, has been deleted, or belongs to a different workspace is rejected with 403; a markupId that isn’t a non-empty string is rejected with 400. The rejection is deliberately identical in all three cases, so it can’t be used to discover whether a MarkUp exists somewhere else.

The exchange response echoes the claim back as markupId, so you can tell a scoped session from a workspace-wide one without decoding the token.

Omit the claim and nothing changes. Sessions without markupId behave exactly as they always have, so this is purely opt-in — you don’t need to change anything to keep working.

If you persist the session (sessionStorage: "sessionStorage" or "localStorage"), the SDK stores it under the MarkUp it was minted for. A stored session is never read back for a different MarkUp under the same publicKey, so navigating between MarkUps gets a token for the one you’re on rather than reusing a scoped token that would be rejected. With the default "memory" mode nothing is persisted, so this doesn’t apply.

Sign the claim for the MarkUp you are rendering. If the markupId in the claim doesn’t match the markupId passed to MarkUpSDK.init(), the SDK throws a configuration error naming both — it will not fall back to an unscoped session.

Get the expected issuer and audience

The iss and aud your token must carry are defined by your installation. Fetch them from the config endpoint using your publicKey:

GET /api/v2/auth/config
curl "https://api.markup.io/api/v2/auth/config?publicKey=<YOUR_PUBLIC_KEY>" \ -X GET \ -H "Markup-API-Version: 2023-02-22" \ -H "Content-Type: application/json"
200 OK
{ "data": { "installationId": "...", "expectedIss": "https://api.markup.io", "expectedAud": "https://api.markup.io/v2/auth/exchange" } }

Sign the token on your backend

A minimal Node.js endpoint that signs a token for the current user:

server: /api/markup-token
import fs from "node:fs"; import {randomUUID} from "node:crypto"; import jwt from "jsonwebtoken"; const PRIVATE_KEY = fs.readFileSync("private.pem", "utf8"); // From GET /api/v2/auth/config for your installation: const ISSUER = "https://api.markup.io"; const AUDIENCE = "https://api.markup.io/v2/auth/exchange"; export function createMarkupToken(user: {id: string; name: string; email: string}) { const now = Math.floor(Date.now() / 1000); return jwt.sign( { iss: ISSUER, aud: AUDIENCE, sub: user.id, iat: now, exp: now + 5 * 60, // 5 minutes jti: randomUUID(), name: user.name, email: user.email }, PRIVATE_KEY, {algorithm: "RS256"} ); }

Sign tokens only on your server, behind your own authentication. Never ship the private key to the browser, and never let an unauthenticated caller mint a token for an arbitrary sub.

For the raw endpoint reference (/auth/config and /auth/exchange), see the Feedback authentication API docs.


Mode B — Sign in with MarkUp

Omit onTokenNeeded and the SDK signs users into their own MarkUp accounts. This is the fastest way to get started and needs no backend.

SDK-managed sign-in
const markup = MarkUpSDK.init({ publicKey: "<YOUR_PUBLIC_KEY>", markupId: "<YOUR_MARKUP_ID>" }); markup.render();

How it works

  1. Silent SSO — on render() the SDK makes a single silent attempt through a hidden, origin-locked iframe. If the user already has a MarkUp session, they’re signed in with no interaction.
  2. Interactive sign-in — if the silent attempt fails, the toolbar shows a Sign in button. Clicking it opens a MarkUp sign-in popup; on success the SDK loads data automatically.

Any registered MarkUp user can sign in — they don’t need to be a member of the MarkUp’s workspace. Members receive their workspace-derived role; a non-member is granted comment access when the MarkUp has an active public share link (“Anyone with the share link has access”), mirroring guest access on the MarkUp website. If the MarkUp is private and the user isn’t a member, sign-in is rejected with WORKSPACE_FORBIDDEN.

Allowed origins are required for this flow. Sign in with MarkUp only mints a session for an origin listed in your installation’s allowed origins, matched against the origin where your app (and the SDK) runs. An installation with no origins configured — or one created with Allow all origins — can’t use this flow; authenticate with a backend-signed token instead.

Sign-in errors

Interactive sign-in can reject with a MarkUpSDKError whose code is one of:

SDKErrorCodeMeaning
POPUP_BLOCKEDThe browser blocked the sign-in popup (window.open failed).
POPUP_CLOSEDThe user closed the popup before completing sign-in.
WORKSPACE_FORBIDDENThe user can’t access this MarkUp — not a workspace member and the MarkUp has no active public share link.
SDK_SIGNIN_FAILEDSign-in failed for another reason.

Sessions

Once resolved, a session is cached and refreshed automatically before it expires, so users don’t have to re-authenticate on every page load. Control where the session is persisted with the sessionStorage option:

ValueBehavior
"memory" (default)Session lives only for the lifetime of the page.
"sessionStorage"Session persists for the browser tab/session.
"localStorage"Session persists across tabs and reloads.

Security tradeoff. "sessionStorage" and "localStorage" persist the MarkUp session token where any script running on your page can read it. Keep the default "memory" unless you specifically need the session to survive reloads, and only enable persistence on origins you trust.

Persist the session
MarkUpSDK.init({ publicKey: "<YOUR_PUBLIC_KEY>", markupId: "<YOUR_MARKUP_ID>", sessionStorage: "localStorage" });

Origins are fixed at build time

The MarkUp API origin and the sign-in website origin are frozen into the SDK bundle at build time (from MARKUP_API_URL / MARKUP_APP_URL). There is no runtime URL option in init, and the website origin is the only origin the SDK accepts sign-in messages from. This keeps the sign-in handshake locked to MarkUp’s own domains. (This is separate from your installation’s allowed origins, which are the origins of your app.)


Next steps

Last updated on