Skip to content

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 resourceJWT carried 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)

ClaimTypeDescription
issstringPublisher domain (e.g. www.news-site.com). Used by the issuer for signing-key lookup
substringResource identifier (publisher's article/resource id)
iatnumberIssued-at (Unix seconds)
jtistringPer-render token id
scopesstring[](Optional) Required access scopes for this resource
dataobjectPublisher-defined metadata for access decisions
ClaimTypeDescription
type"dca-share"Discriminator — always "dca-share"
domainstringPublisher domain (must match the resource's domain)
resourceIdstringResource this token grants access to
contentNamesstring[]Content items (by contentName) this token grants access to
scopesstring[](Optional) Scopes granted. Mutually exclusive with contentNames
iatnumberIssued-at (Unix seconds)
expnumberExpiry (Unix seconds)
maxUsesnumber(Optional) Advisory maximum redemptions — enforced by the issuer
jtistringToken id for revocation / use-count tracking
dataobject(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's verifyJwt() (or createRemoteJWKSet pointed 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

  1. Load the publisher's ES256 public key (or its JWKS). Cache it — fetching on every request is wasteful.
  2. Verify the JWT signature using ES256.
  3. Check iat is not in the future and (for share link tokens) exp is not in the past. Allow 30–60s of clock skew.
  4. Check iss (resourceJWT) or domain (share link token) matches the publisher domain you expect.
  5. Check the resource-level claim:
    • resourceJWT: sub matches the resource the caller is requesting, and the intersection of scopes with the caller's entitlements is non-empty.
    • Share link token: resourceId matches the resource the caller is requesting, and the requested contentName is in contentNames (or covered by scopes).

Reject the token if any step fails.

Examples

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.

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

typescript
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

http
POST https://api2.sesamy.com/capsule/vendors/{vendorId}/verify
Content-Type: application/json

{ "token": "eyJhbGc..." }

Request body

FieldTypeDescription
tokenstringThe signed token to verify (from a signed link or paywalls.checkAccess response)

Response

FieldTypeDescription
validbooleantrue if the signature is valid and the token has not expired
messagestring(Optional) Reason the token was rejected (e.g. Token expired, Invalid signature)
payloadobject(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

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

http
GET https://api2.sesamy.com/capsule/vendors/{vendorId}/.well-known/jwks.json

The JWKS response is standard:

json
{
  "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.
  • resourceJWT has no exp. Add your own age bound (iat + maxAge) when you accept it outside the render → unlock path it was designed for.
  • maxUses on share link tokens is advisory. Enforcement lives on the issuer (createDcaIssuer). If you need hard redemption limits on your own surface, track jti yourself.

Next Steps

Released under the MIT License.