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:
onTokenNeeded → cached session → silent SSO → interactive sign-inIf 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
- The SDK calls your
onTokenNeededcallback. - Your backend signs a short-lived JWT with the private key for your SDK installation (RS256).
onTokenNeededreturns that JWT to the SDK.- The SDK exchanges it via
POST /api/v2/auth/exchangefor a short-lived MarkUp session token, and uses that for subsequent API calls.
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.
| Option | Type | |
|---|---|---|
| iss | string | Issuer. Must match the |
| aud | string | Audience. Must match the |
| 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 optional | string | Display name shown in the toolbar and on the user's comments. |
| email optional | string | User email. |
| avatarUrl optional | string | URL of the user's avatar image. |
| markupRole optional | string | Feedback role for this user: |
| markupId optional | string | 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.
markupRole | The user can |
|---|---|
project:viewer | Read the project, its threads and its comments — a read-only embed. No commenting. |
project:contributor | Everything a viewer can, plus post comments and resolve threads. |
project:owner | Everything 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.
{
// ...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
markupIdbehave 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
markupIdin the claim doesn’t match themarkupIdpassed toMarkUpSDK.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:
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"{
"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:
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.
const markup = MarkUpSDK.init({
publicKey: "<YOUR_PUBLIC_KEY>",
markupId: "<YOUR_MARKUP_ID>"
});
markup.render();How it works
- 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. - 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:
SDKErrorCode | Meaning |
|---|---|
POPUP_BLOCKED | The browser blocked the sign-in popup (window.open failed). |
POPUP_CLOSED | The user closed the popup before completing sign-in. |
WORKSPACE_FORBIDDEN | The user can’t access this MarkUp — not a workspace member and the MarkUp has no active public share link. |
SDK_SIGNIN_FAILED | Sign-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:
| Value | Behavior |
|---|---|
"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.
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 ininit, 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
- Configuration — all
initandrenderoptions. - Create an SDK installation — generate the key pair
onTokenNeededsigns with. - API reference — error classes and
SDKErrorCodevalues.