Publisher Server Setup
How to configure @sesamy/capsule-server in your backend so rendered pages produce a DCA manifest that sesamy-js can decrypt in the browser.
Who this is for
You need this guide if your CMS, SSG, or API renders pages (WordPress, Next.js, custom Node / Workers backend) and you want to encrypt premium content with Capsule. Static websites using only the Scripts Host bundle do not need it -- Sesamy renders the manifest for you.
Install
pnpm add @sesamy/capsule-server@sesamy/capsule-server runs on Node 18+, Cloudflare Workers, Deno, and modern browsers -- it uses the Web Crypto API with no native dependencies.
Keys you need
Capsule separates two key responsibilities:
| Key | Purpose | Who holds it |
|---|---|---|
| Publisher ES256 signing key (ECDSA P-256) | Signs the resourceJWT that binds each rendered page to your publisher domain | Your backend (private), Sesamy (public, for verification) |
| Rotation secret (32 random bytes) | Input to HKDF-derived wrapKey per scope and rotation version | Your backend and Sesamy (shared secret) |
You upload the public signing key and the rotation secret to Sesamy via the management API or dashboard. Sesamy exposes the public signing key back at:
https://api2.sesamy.com/capsule/vendors/{vendorId}/.well-known/jwks.jsonThat JWKS carries use: "sig" keys and is what third parties use to verify your resourceJWT or share link tokens -- see Token Validation.
Register the publisher with Sesamy
Once you have generated an ES256 keypair, register the public half with Sesamy so the unlock endpoint will accept tokens signed by your publisher domain. Registration is per-vendor: the calling token's vendor_id claim determines which vendor the publisher is bound to, so a single M2M client can only manage publishers under its own vendor. There is no vendorId in the path -- cross-vendor writes are impossible by construction.
Public keys only
These endpoints register the publisher signing key. The rotation secret is provisioned separately by Sesamy alongside your vendor configuration -- it is never sent over this API.
Authentication
The endpoints sit under the Management API and use OAuth 2.0 client credentials. Request a token with the appropriate scope:
curl -X POST https://token.sesamy.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=domains:write"| Operation | Required scope |
|---|---|
| List or read trusted domains | domains:read |
| Register, update, or delete trusted domains | domains:write |
The issued token must carry a vendor_id claim. Contact Sesamy if your client credentials are not yet bound to a vendor.
Two registration modes
Choose one per publisher -- they are mutually exclusive.
Pinned signingKeyPem -- a single ES256 (P-256) public key in PEM format. Sesamy verifies every resourceJWT and share link token signed by your domain against this exact key. Use this if you do not plan to rotate signing keys often, or you have nowhere to host a JWKS document.
jwksUri -- an HTTPS URL serving a JWKS document with one or more active public keys. Sesamy fetches the JWKS lazily and matches incoming tokens by kid. Use this when you want to rotate keys without coordinating with Sesamy: publish the new key in your JWKS, leave the old one for the rollover window, then drop it.
The jwksUri host must match {domain} exactly and the URL must use https://. After registration, Sesamy probes the URL once and reports the result on the response. The probe is non-blocking, so registration succeeds even if your JWKS is not yet reachable.
Endpoints
Base URL: https://api2.sesamy.com/management/domains
Register or update a trusted domain
PUT /management/domains/{domain}{domain} is the lowercase hostname your createDcaPublisher({ domain }) config uses, with no scheme and no trailing dot.
| Field | Type | Description |
|---|---|---|
signingKeyPem | string | (Optional) Pinned ES256 public key in PEM format. Mutually exclusive with jwksUri |
jwksUri | string | (Optional) HTTPS URL serving the publisher JWKS. Hostname must match {domain}. Mutually exclusive with signingKeyPem |
allowedResourceIds | array | (Optional) Restrict this publisher to specific resource IDs. Currently informational; not enforced at unlock time |
Returns 201 Created on first registration and 200 OK on subsequent updates. The response includes the stored entry plus, in jwksUri mode, a probe field reporting whether the JWKS was reachable.
curl -X PUT https://api2.sesamy.com/management/domains/www.news-site.com \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"signingKeyPem": "-----BEGIN PUBLIC KEY-----\nMFkwEw...\n-----END PUBLIC KEY-----\n"
}'curl -X PUT https://api2.sesamy.com/management/domains/www.news-site.com \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jwksUri": "https://www.news-site.com/.well-known/dca-publishers.json"
}'Public key only
Never send a private key. The endpoint rejects any PEM containing the PRIVATE marker.
List, read, and delete
GET /management/domains
GET /management/domains/{domain}
DELETE /management/domains/{domain}DELETE returns 204 No Content whether or not the entry existed (idempotent). Removing a trusted domain disables unlock for any token signed under that domain.
After registration, Sesamy verifies your resourceJWT and share link tokens against the registered key (or the most recently fetched JWKS) when readers request unlock at https://api2.sesamy.com/capsule/vendors/{vendorId}/unlock.
Minimum publisher wiring
import { createDcaPublisher } from '@sesamy/capsule-server';
const publisher = createDcaPublisher({
domain: 'www.news-site.com',
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY_PEM!,
rotationSecret: process.env.ROTATION_SECRET_BASE64!,
});
const result = await publisher.render({
resourceId: 'article-2025-001',
contentItems: [
{ contentName: 'bodytext', scope: 'premium', content: '<p>...premium HTML...</p>' },
],
issuers: [
{
issuerName: 'sesamy',
publicKeyPem: process.env.SESAMY_ISSUER_PUBLIC_KEY_PEM!,
keyId: 'sesamy-ecdh-2025',
unlockUrl: 'https://api2.sesamy.com/capsule/vendors/YOUR_VENDOR_ID/unlock',
scopes: ['premium'],
},
],
resourceData: {
title: 'Article title',
price: 29,
currency: 'SEK',
},
});
// Inject `result.html.manifestScript` into the page template.The scope on each content item is the access tier. Anything in the scope list on the sesamy issuer config gets wrapped for Sesamy, meaning Sesamy can unwrap and return the wrapKey when the reader has that entitlement.
JWKS-based issuer discovery (@sesamy/capsule-server 0.12+)
Instead of hardcoding publicKeyPem + keyId for the issuer, you can point capsule-server at a JWKS URL and let it auto-resolve the currently-active public keys:
issuers: [
{
issuerName: 'sesamy',
jwksUri: 'https://issuer.example.com/capsule/.well-known/jwks.json',
unlockUrl: 'https://api2.sesamy.com/capsule/vendors/YOUR_VENDOR_ID/unlock',
scopes: ['premium'],
},
],With jwksUri:
publicKeyPemandkeyIdare omitted -- each active JWKS key carries its ownkid.- During key rotation, capsule-server wraps content for every currently-active key so either the old or new private key can unwrap successfully. No coordinated cutover needed.
- The JWKS is fetched once and cached per
Cache-Control: max-age(fallback 1 hour).
Not applicable to Sesamy as issuer today
Sesamy's current unlock flow is symmetric -- the wrapKey is derived from a shared rotation secret via HKDF, so there is no asymmetric issuer public key to fetch. jwksUri is useful if you run your own DCA issuer (e.g. for a self-hosted subscription service) or if a future Sesamy deployment exposes encryption keys via JWKS. For the hosted Sesamy issuer, the symmetric flow handled by the /unlock endpoint is the supported path.
Caching JWKS across workers
The default cache is a per-process Map -- fine for a single Node process, lost on restart, and not shared between Cloudflare Worker isolates. For production on Workers, Redis, or similar, provide a persistent backend:
import { createDcaPublisher, type DcaJwksCache } from '@sesamy/capsule-server';
const jwksCache: DcaJwksCache = {
async get(url) {
const raw = await env.JWKS_CACHE.get(url, 'json');
return raw ?? null;
},
async set(url, entry) {
await env.JWKS_CACHE.put(url, JSON.stringify(entry), {
expirationTtl: Math.ceil((entry.staleUntil - Date.now()) / 1000),
});
},
async delete(url) {
await env.JWKS_CACHE.delete(url);
},
};
const publisher = createDcaPublisher({
domain: 'www.news-site.com',
signingKeyPem: process.env.PUBLISHER_ES256_PRIVATE_KEY_PEM!,
rotationSecret: process.env.ROTATION_SECRET_BASE64!,
jwksCache,
jwksStaleWindowSeconds: 30 * 24 * 3600, // serve stale for 30 days on upstream failure
});jwksStaleWindowSeconds controls how long a cached copy may be served past its freshness window when the upstream fetch fails. Availability beats freshness for wrap operations -- issuer private-key rotation is rare, and a wrap with a slightly stale key is still unwrappable as long as the key has not been retired.
Share link tokens
The publisher can mint a pre-authenticated access grant that bypasses the subscription check:
const shareToken = await publisher.createShareLinkToken({
resourceId: 'article-2025-001',
scopes: ['premium'],
expiresIn: 7 * 24 * 3600,
});
const shareUrl = `https://www.news-site.com/articles/123?share=${shareToken}`;The token is an ES256 JWT signed with the same publisher key. Sesamy verifies it against the JWKS you registered and unlocks without an entitlement check. See Token Validation for how to re-verify tokens server-side when they arrive on your own backend.
Where to go next
- Content Protection -- the DCA manifest shape and how sesamy-js consumes it
- Token Validation -- verify
resourceJWTand share link tokens on your backend - Server-Side Rendering -- inject auth state alongside the manifest for a no-flash first paint