Token Validation
How to verify the two kinds of tokens @sesamy/capsule-server emits on your own backend.
Overview
Capsule publishers sign two closely related JWTs with their own ES256 key:
resourceJWT— embedded in every DCA manifest. Identifies the resource (article, page, SKU) and the scopes required to unlock it. Used by the issuer on every unlock request.- Share link token — a publisher-signed JWT you hand to a reader (share link, gift access, email campaign) that grants pre-authenticated access without a subscription.
Both are signed with the publisher's ES256 private key, not a Sesamy-issued key. The issuer (Sesamy, in the hosted setup) verifies them against the publisher's public key that the publisher registered with the issuer.
When you need this
In a typical SSR flow, the publisher signs the resourceJWT and immediately embeds it in the manifest on the same request — there is nothing to re-verify. Implement the code on this page when a token arrives from an untrusted path:
- a share link token parsed out of a
?share=or?sesamy_token=URL parameter - a
resourceJWTcarried in a request to a non-web surface (mobile app, inbound webhook, cross-service hop, print-subscriber portal) - any situation where the token crosses a trust boundary between where it was minted and where it is consumed
If you control both sides of the flow (same process, same request), you do not need this.
Token shapes
Both tokens are ES256 JWTs (ECDSA using P-256 and SHA-256). The payload shapes come directly from @sesamy/capsule-server:
resourceJWT (DcaResourceJwtPayload)
| Claim | Type | Description |
|---|---|---|
iss | string | Publisher domain (e.g. www.news-site.com). Used by the issuer for signing-key lookup |
sub | string | Resource identifier (publisher's article/resource id) |
iat | number | Issued-at (Unix seconds) |
jti | string | Per-render token id |
scopes | string[] | (Optional) Required access scopes for this resource |
data | object | Publisher-defined metadata for access decisions |
Share link token (DcaShareLinkTokenPayload)
| Claim | Type | Description |
|---|---|---|
type | "dca-share" | Discriminator — always "dca-share" |
domain | string | Publisher domain (must match the resource's domain) |
resourceId | string | Resource this token grants access to |
contentNames | string[] | Content items (by contentName) this token grants access to |
scopes | string[] | (Optional) Scopes granted. Mutually exclusive with contentNames |
iat | number | Issued-at (Unix seconds) |
exp | number | Expiry (Unix seconds) |
maxUses | number | (Optional) Advisory maximum redemptions — enforced by the issuer |
jti | string | Token id for revocation / use-count tracking |
data | object | (Optional) Publisher-defined metadata |
Both tokens identify the publisher as the issuer — not capsule.sesamy.com, and not any Sesamy-hosted issuer URL.
Fetching the publisher's public key
You need the publisher's ES256 public key to verify either token. How you obtain it depends on who is verifying:
- You are the publisher. You already have the public half of the key you signed with — load it from your secrets manager alongside the private key. Pass the PEM to
@sesamy/capsule-server'sverifyJwt()(orcreateRemoteJWKSetpointed at a JWKS document you host yourself). - You are a third party verifying a publisher's token. Ask the publisher where they host their JWKS and fetch it from there. Capsule does not mandate a specific URL for publisher JWKS.
This is not the Sesamy vendor JWKS
https://api2.sesamy.com/capsule/vendors/{vendorId}/.well-known/jwks.json hosts keys for issuer-issued tokens in other Sesamy flows. It is not the key that @sesamy/capsule-server signs resourceJWT or share link tokens with. Using it here will make every token look invalid.
Validation steps
- Load the publisher's ES256 public key (or its JWKS). Cache it — fetching on every request is wasteful.
- Verify the JWT signature using ES256.
- Check
iatis not in the future and (for share link tokens)expis not in the past. Allow 30–60s of clock skew. - Check
iss(resourceJWT) ordomain(share link token) matches the publisher domain you expect. - Check the resource-level claim:
- resourceJWT:
submatches the resource the caller is requesting, and the intersection ofscopeswith the caller's entitlements is non-empty. - Share link token:
resourceIdmatches the resource the caller is requesting, and the requestedcontentNameis incontentNames(or covered byscopes).
- resourceJWT:
Reject the token if any step fails.
Examples
Re-validating a share link token
A reader arrives at your site with a publisher-minted share token in the URL. Before unlocking content, verify the token was actually signed by you and grants access to the resource being requested.
import { verifyJwt, importEcdsaP256PublicKey } from '@sesamy/capsule-server';
import type { DcaShareLinkTokenPayload } from '@sesamy/capsule-server';
const PUBLISHER_DOMAIN = 'www.news-site.com';
// Load once at startup — do not re-import on every request.
const publisherPublicKey = await importEcdsaP256PublicKey(
process.env.PUBLISHER_ES256_PUBLIC_KEY_PEM!
);
export async function validateShareLinkToken(
token: string,
requiredResourceId: string,
requiredContentName: string
): Promise<DcaShareLinkTokenPayload> {
const payload = await verifyJwt<DcaShareLinkTokenPayload>(token, publisherPublicKey);
if (payload.type !== 'dca-share') {
throw new Error('Not a share link token');
}
if (payload.domain !== PUBLISHER_DOMAIN) {
throw new Error('Token signed for a different domain');
}
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now - 30) {
throw new Error('Token expired');
}
if (payload.resourceId !== requiredResourceId) {
throw new Error('Token does not grant access to this resource');
}
const grantsContent =
payload.contentNames?.includes(requiredContentName) ?? false;
if (!grantsContent) {
throw new Error('Token does not grant access to this content item');
}
return payload;
}Re-validating a resourceJWT
Use this when a resourceJWT reaches your backend from an untrusted path (a cross-service call, a cached page whose signing you do not trust, a mobile client sending it as proof).
import { verifyJwt, importEcdsaP256PublicKey } from '@sesamy/capsule-server';
import type { DcaResourceJwtPayload } from '@sesamy/capsule-server';
const PUBLISHER_DOMAIN = 'www.news-site.com';
const MAX_AGE_SECONDS = 60 * 60; // 1 hour
const publisherPublicKey = await importEcdsaP256PublicKey(
process.env.PUBLISHER_ES256_PUBLIC_KEY_PEM!
);
export async function validateResourceJwt(
token: string,
requiredResourceId: string,
userEntitlements: string[]
): Promise<DcaResourceJwtPayload> {
const payload = await verifyJwt<DcaResourceJwtPayload>(token, publisherPublicKey);
if (payload.iss !== PUBLISHER_DOMAIN) {
throw new Error('Token issued by a different publisher');
}
const now = Math.floor(Date.now() / 1000);
if (payload.iat > now + 30) {
throw new Error('Token issued in the future');
}
// resourceJWT has no `exp` — the issuer treats it as freshly-rendered. Add
// your own age bound so a stale token cannot be replayed indefinitely.
if (now - payload.iat > MAX_AGE_SECONDS) {
throw new Error('Token too old to trust on this path');
}
if (payload.sub !== requiredResourceId) {
throw new Error('Token covers a different resource');
}
if (payload.scopes && payload.scopes.length > 0) {
const granted = payload.scopes.some((s) => userEntitlements.includes(s));
if (!granted) {
throw new Error('User does not hold any of the required scopes');
}
}
return payload;
}Verifying a generic Sesamy-issued token
Only if Sesamy issued the token
Use this only when you are consuming a JWT that Sesamy (not your publisher code) minted — for example, the signed link returned from paywalls.checkAccess (the signedLink URL with a ?sesamy_token= parameter), or a token issued by the management capsule API. For resourceJWT and share link tokens produced by @sesamy/capsule-server, use the publisher-key flow above.
vendorId is not clientId
The vendor identifier in the URLs below is not always the same as the clientId you use with sesamy-js. Check your vendor configuration in the Sesamy dashboard if you are not sure.
You have two options. Use the /verify endpoint when you want a single HTTP call. Use the JWKS flow when you want to verify offline after a one-time key fetch.
Option 1: POST /capsule/vendors/{vendorId}/verify
Send the token to the Sesamy API and get back a structured result. No authentication is required — the token itself is the credential.
Endpoint
POST https://api2.sesamy.com/capsule/vendors/{vendorId}/verify
Content-Type: application/json
{ "token": "eyJhbGc..." }Request body
| Field | Type | Description |
|---|---|---|
token | string | The signed token to verify (from a signed link or paywalls.checkAccess response) |
Response
| Field | Type | Description |
|---|---|---|
valid | boolean | true if the signature is valid and the token has not expired |
message | string | (Optional) Reason the token was rejected (e.g. Token expired, Invalid signature) |
payload | object | (Optional) Decoded token payload when valid is true |
The endpoint returns 200 for both valid and invalid tokens — valid: false means the verification ran successfully and the token did not pass. A 404 response means the vendor has no Capsule configuration.
The payload object contains the standard Sesamy-issued token claims: iss, tier, contentId, tid, iat, exp, and the optional url, userId, maxUses, and meta fields.
Example
async function verifySesamyToken(vendorId: string, token: string) {
const response = await fetch(
`https://api2.sesamy.com/capsule/vendors/${vendorId}/verify`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
}
);
if (!response.ok) {
throw new Error(`Verify failed: ${response.status}`);
}
const result = await response.json();
if (!result.valid) {
throw new Error(`Token rejected: ${result.message}`);
}
return result.payload;
}Still verify the resource claim yourself
The endpoint confirms the signature and expiry. You still need to check that payload.contentId (or payload.url) matches the resource the caller is requesting before granting access.
Option 2: Vendor JWKS (offline verification)
Fetch the vendor JWKS once and verify the signature locally. This avoids a network round trip on every request.
GET https://api2.sesamy.com/capsule/vendors/{vendorId}/.well-known/jwks.jsonThe JWKS response is standard:
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"kid": "premium",
"use": "sig",
"alg": "ES256"
}
]
}Verify the signature against the JWKS, check exp and iss, and check whichever resource claim (contentId, url, sku) the Sesamy endpoint that issued the token documents. Consult the specific Sesamy API you are integrating against for the exact claim names — they differ between the signed-link endpoint and other token issuers.
Security notes
- Cache the signing key / JWKS. Fetching it on every request adds latency and pressure to the source. A one-hour cache with a background refresh is reasonable.
- Always verify the signature. Never trust claims from a decoded-but-unverified token.
- Bind to the expected issuer. Verifying the signature alone is not enough if your backend can encounter tokens from multiple publishers.
- Allow a small clock skew. 30 to 60 seconds is typical. Larger windows weaken the expiry guarantee.
resourceJWThas noexp. Add your own age bound (iat + maxAge) when you accept it outside the render → unlock path it was designed for.maxUseson share link tokens is advisory. Enforcement lives on the issuer (createDcaIssuer). If you need hard redemption limits on your own surface, trackjtiyourself.
Next Steps
- Content Protection — How Capsule encrypts content and how the
resourceJWTis delivered - Leaky Paywall — Where signed links appear in the paywall response
- URL Handlers — How sesamy-js consumes signed links in the browser
- Implementing Paywalls — The broader paywall integration flow