Skip to content

JWT Structure

ShopHero uses JSON Web Tokens (JWT) for authentication. Understanding the token structure helps with debugging and implementing advanced authentication scenarios.

Token Format

JWT tokens consist of three parts separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwcC5yZXRhaWxzdWNjZXNzcGxhdGZvcm0uY29tIiwic3ViIjoiOWExYjJjM2QtNGU1Zi02Nzg5LWFiY2QtZWYwMTIzNDU2Nzg5IiwiY2xpZW50X2lkIjoiOWExYjJjM2QtNGU1Zi02Nzg5LWFiY2QtZWYwMTIzNDU2Nzg5Iiwic2NvcGVzIjpbImVuZ2FnZWhxOioiXSwiaWF0IjoxNzA1MzI0ODAwLCJleHAiOjE3MDU0MTEyMDB9.signature
PartDescription
HeaderAlgorithm and token type
PayloadClaims (user/client data)
SignatureCryptographic signature

The header specifies the signing algorithm and the key identifier:

json
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key_001"
}
FieldValueDescription
algRS256RSA with SHA-256 signature
typJWTToken type
kidstringKey identifier — selects the public key from /.well-known/jwks.json to verify this token's signature. Required for multi-key rotation.

Payload (Claims)

Service Account Token

Tokens issued for service accounts contain:

json
{
  "iss": "https://app.retailsuccessplatform.com",
  "aud": "*",
  "sub": "9a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "jti": "a7b8c9d0-1e2f-3456-7890-abcdef012345",
  "iat": 1705324800,
  "nbf": 1705324800,
  "exp": 1705411200,
  "token_type": "service_account",
  "client_id": "9a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "service_name": "engagehq",
  "scopes": ["engagehq:content.view", "engagehq:circulars.view"],
  "permissions": ["engagehq:content.view", "engagehq:circulars.view"],
  "is_service_account": true,
  "organization_id": "org_00000k1L2m3N4o5",
  "accessible_orgs": ["org_00000k1L2m3N4o5"],
  "version": 1
}

Claim Definitions

ClaimTypeDescription
issstringIssuer - always https://app.retailsuccessplatform.com
audstringAudience - always *
substringSubject - the OAuth client UUID
jtistringUnique token identifier (UUID)
iatintegerIssued at (Unix timestamp)
nbfintegerNot valid before (Unix timestamp)
expintegerExpiration (Unix timestamp)
token_typestringToken type — canonical predicate. service_account for service-account tokens, user for user tokens, device for device tokens, stream_app for stream-app tokens.
client_idstringOAuth client UUID
service_namestringPrimary service for this token
scopesarrayGranted permission scopes
permissionsarraySame as scopes (for compatibility)
is_service_accountbooleanDeprecated. Legacy boolean marker — use token_type === "service_account" instead. No longer emitted on newly minted tokens; in-flight tokens may still carry it until they expire. Will be removed in a future release.
organization_idstringPrimary organization context (hashkey)
accessible_orgsarrayAll accessible organization hashkeys
versionintegerToken version

CommerceStream channel claims

User, service-account, and user-delegated tokens include CommerceStream channel permissions derived from the granted permission set. Each granted permission's commercestream metadata in the registry contributes patterns to these claims.

ClaimTypeDescription
channel_permissionsobjectCanonical name. Shape: { "subscribe": [...], "publish": [...], "replay": [...] }. Each list contains CommerceStream pattern strings the token may use for the corresponding action.
cs_permissionsobjectTransitional alias for channel_permissions during the migration window introduced by ADR-026. Identical value. New consumers should read channel_permissions; the alias will be removed in a future release.

Decoding Tokens

JavaScript

javascript
function decodeToken(token) {
  const [header, payload, signature] = token.split('.');

  return {
    header: JSON.parse(atob(header)),
    payload: JSON.parse(atob(payload)),
    signature: signature
  };
}

// Usage
const decoded = decodeToken(accessToken);
console.log('Expires at:', new Date(decoded.payload.exp * 1000));
console.log('Scopes:', decoded.payload.scopes);

PHP

php
function decodeToken(string $token): array
{
    [$header, $payload, $signature] = explode('.', $token);

    return [
        'header' => json_decode(base64_decode($header), true),
        'payload' => json_decode(base64_decode($payload), true),
        'signature' => $signature
    ];
}

// Usage
$decoded = decodeToken($accessToken);
$expiresAt = (new DateTime())->setTimestamp($decoded['payload']['exp']);
$scopes = $decoded['payload']['scopes'];

Python

python
import base64
import json

def decode_token(token: str) -> dict:
    header, payload, signature = token.split('.')

    # Add padding if needed
    def decode_part(part):
        padding = 4 - len(part) % 4
        part += '=' * padding
        return json.loads(base64.urlsafe_b64decode(part))

    return {
        'header': decode_part(header),
        'payload': decode_part(payload),
        'signature': signature
    }

# Usage
decoded = decode_token(access_token)
expires_at = datetime.fromtimestamp(decoded['payload']['exp'])
scopes = decoded['payload']['scopes']

Don't Trust Decoded Tokens

Decoding a JWT only reveals its contents. You must verify the signature to trust the data. API services handle verification automatically.

Token Verification

ShopHero APIs automatically verify tokens, but if you need to verify tokens locally:

Verification Steps

  1. Check expiration - exp must be in the future
  2. Verify signature - Using the public key
  3. Validate issuer - iss must be https://app.retailsuccessplatform.com
  4. Check scopes - Ensure required scopes are present

Getting Public Keys

Retrieve public keys from the Identity service:

bash
curl https://identity.retailsuccessplatform.com/api/public-keys

Response:

json
{
  "keys": [
    {
      "kid": "key_001",
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

JavaScript Verification

javascript
import jwt from 'jsonwebtoken';

async function verifyToken(token) {
  // Fetch public key
  const keysResponse = await fetch('https://identity.retailsuccessplatform.com/api/public-keys');
  const { keys } = await keysResponse.json();
  const publicKey = keys[0]; // Use key matching `kid` in token header

  try {
    const decoded = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],
      issuer: 'https://app.retailsuccessplatform.com'
    });

    return { valid: true, claims: decoded };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

Checking Token Expiration

Always check expiration before using a cached token:

javascript
function isTokenExpired(token, bufferSeconds = 300) {
  const decoded = decodeToken(token);
  const expiresAt = decoded.payload.exp * 1000; // Convert to milliseconds
  const buffer = bufferSeconds * 1000;

  return Date.now() >= (expiresAt - buffer);
}

// Usage
if (isTokenExpired(cachedToken)) {
  cachedToken = await getNewToken();
}

Token Debugging

Common Issues

Token Expired

exp: 1705324800 (2024-01-15 10:00:00)
Current: 1705411200 (2024-01-16 10:00:00)

Solution: Request a new token.

Wrong Scopes

Required: engagehq:offers.view
Token scopes: ["engagehq:content.view"]

Solution: Request a token with the correct scopes.

Invalid Organization

Token org_id: org_00000k1L2m3N4o5
Resource org_id: org_00000m2N3o4P5q6

Solution: Ensure your service account has access to the target organization.

Debug Tool

Use jwt.io to decode and inspect tokens during development:

  1. Go to jwt.io
  2. Paste your token in the "Encoded" field
  3. View the decoded header and payload

Security

Never paste production tokens into third-party websites. Use jwt.io only for development/test tokens.


Changelog
DateChange
2026-04-25Documented kid header (multi-key rotation per ADR-026). Marked is_service_account deprecated — token_type is the canonical predicate; the legacy boolean is no longer emitted on newly minted tokens.
2026-03-14Accuracy fixes and added missing content.
2026-02-27Documentation corrections.
2026-01-15Initial publication.

ShopHero CommerceCore Platform