Predictu
S2S Protocol

JWT Authentication

Every S2S callback from Predictu includes a cryptographically signed JWT in theAuthorization header. This allows operators to verify that the request genuinely originated from Predictu and has not been tampered with in transit.

How it works: Predictu signs each callback with an RSA key unique to your operator. You verify signatures using the public key we provide. The algorithm is RS256.

How JWT Signing Works

1
Predictu builds the request body - The S2S request envelope is serialized to JSON.
2
Predictu computes a digest of the JSON body and includes it as the digest claim in the JWT. This binds the JWT to the specific request body.
3
Predictu signs the JWT using your operator's private key. The JWT includes standard claims (iss, sub,iat, exp, jti) plus custom claims (method,digest).
4
Predictu sends the callback with the JWT in the Authorization: Bearerheader and the JSON body in the POST body.
5
Operator verifies the JWT using Predictu's public key, checks all claims, verifies the body digest, and confirms the JTI has not been replayed.
  Predictu                                    Operator Backend
  ────────                                    ────────────────
  Build request body (JSON)
       │
  Compute body digest
       │
  Sign JWT (private key)
  Claims: { iss, sub, iat, exp, jti,
            method, digest }
       │
  POST callback_url
  Authorization: Bearer <jwt>
  Body: <json>
       ├───────────────────────────────────────►│
       │                                        │
       │                        Verify JWT with public key
       │                        Check iss == "predictu"
       │                        Check exp > now
       │                        Check jti not replayed
       │                        Verify body digest matches
       │                                        │
       │                        Process callback
       │          { status: "OK", balance }      │
       │◄───────────────────────────────────────┤

JWT Claims

The JWT payload contains both standard (RFC 7519) and custom claims:

ClaimTypeStandardDescription
issstringYesIssuer. Always "predictu". Verify this matches exactly.
substringYesSubject. The operator_id this JWT was signed for. Verify this matches your operator ID.
iatnumberYesIssued At. Unix timestamp (seconds) when the JWT was created.
expnumberYesExpiration. Unix timestamp. Set to iat + 30 (30-second TTL). Reject expired tokens.
jtistringYesJWT ID. Set to the request_id (UUID). Use this for replay protection.
methodstringCustomThe S2S method being called (e.g. BET_MAKE). Cross-reference with the body's method field.
digeststringCustomSHA-256 hex digest of the raw JSON request body. Prevents body tampering.

Example JWT Payload (Decoded)

{
  "alg": "RS256",
  "typ": "JWT"
}
.
{
  "iss": "predictu",
  "sub": "op_abc123",
  "iat": 1742392200,
  "exp": 1742392230,
  "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "method": "BET_MAKE",
  "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
.
<RS256 signature>

Authorization Header Format

The JWT is sent in the standard HTTP Bearer token format:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwcmVkaWN0dSIs...
// Extract the JWT from the Authorization header
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith("Bearer ")) {
  return { valid: false, error: "Missing or invalid Authorization header" };
}

const token = authHeader.slice(7); // Remove "Bearer " prefix

Key Generation

When an operator is onboarded, Predictu generates a unique signing key pair for that operator. The private key is stored securely on Predictu's servers and used to sign outbound JWTs. The public key is made available to the operator via the API for signature verification.

Operators never see the private key. The private key is used exclusively by Predictu's backend to sign outbound JWTs. Operators only receive the public key for verification.

Fetching the Public Key

Operators can fetch their public key from Predictu's API. This key is used to verify JWT signatures on incoming S2S callbacks.

GET/api/s2s/operators/{operator_id}/public-key

Response

{
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
  "algorithm": "RS256",
  "created_at": "2026-03-19T10:00:00.000Z"
}

Caching

The public key rarely changes (only during key rotation). Cache it in memory or on disk. If JWT verification starts failing unexpectedly, re-fetch the public key in case it was rotated.

// Example: Load and cache the public key (Node.js)
const { importSPKI } = require("jose");

let cachedPublicKey = null;

async function getPublicKey() {
  if (cachedPublicKey) return cachedPublicKey;

  const res = await fetch(
    "https://casino.predictu.com/api/s2s/operators/op_abc123/public-key"
  );
  const { public_key } = await res.json();

  cachedPublicKey = await importSPKI(public_key, "RS256");
  return cachedPublicKey;
}

// Clear cache when key rotation is detected
function clearPublicKeyCache() {
  cachedPublicKey = null;
}

Operator Verification Steps

When you receive an S2S callback, follow these six steps to verify the JWT:

1
Extract the JWT from the Authorization: Bearer {token} header.
2
Verify the signature using Predictu's public key. This proves the JWT was signed by Predictu and has not been modified.
3
Check the issuer - iss must equal "predictu".
4
Check expiration - exp must be in the future. The TTL is 30 seconds, so allow a small clock tolerance (15 seconds recommended).
5
Check JTI replay - Look up the jti in your seen_jtistable. If it exists, this is a replayed token - reject it. If it's new, record it.
6
Verify body digest - Compute SHA-256(rawBody) and compare it to the digest claim. If they don't match, the body has been tampered with.

Reference Implementation

Below is the complete JWT verification code from the casino demo. This is the reference implementation for operators to adapt into their own stack.

const { jwtVerify, importSPKI } = require("jose");
const crypto = require("crypto");

async function verifyS2SCallback(authHeader, bodyString) {
  // Step 1: Extract JWT from Authorization header
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return { valid: false, error: "Missing or invalid Authorization header" };
  }
  const token = authHeader.slice(7);

  // Step 2 & 3: Verify signature and check issuer
  const publicKey = await loadPublicKey();
  const { payload } = await jwtVerify(token, publicKey, {
    issuer: "predictu",
    clockTolerance: 15, // 15 seconds tolerance for clock skew
  });

  // Step 5: Check JTI replay
  if (payload.jti) {
    if (hasSeenJti(payload.jti)) {
      return { valid: false, error: "Replayed JTI" };
    }
    markJtiSeen(payload.jti);
  }

  // Step 6: Verify body digest
  if (payload.digest && bodyString) {
    const expectedDigest = crypto
      .createHash("sha256")
      .update(bodyString)
      .digest("hex");

    if (payload.digest !== expectedDigest) {
      return { valid: false, error: "Body digest mismatch - possible tampering" };
    }
  }

  return { valid: true, payload };
}

JTI Replay Protection

The jti (JWT ID) claim prevents replay attacks. An attacker who intercepts a valid JWT cannot reuse it because your server tracks every JTI it has seen.

Database Schema

CREATE TABLE IF NOT EXISTS seen_jtis (
  jti TEXT PRIMARY KEY,
  created_at TEXT DEFAULT (datetime('now'))
);

Implementation

function hasSeenJti(jti) {
  const row = db.prepare(
    "SELECT jti FROM seen_jtis WHERE jti = ?"
  ).get(jti);
  return !!row;
}

function markJtiSeen(jti) {
  db.prepare(
    "INSERT OR IGNORE INTO seen_jtis (jti) VALUES (?)"
  ).run(jti);
}
Cleanup: Since JWTs have a 30-second TTL, you can safely prune seen_jtisentries older than a few minutes. A daily cleanup job removing entries older than 1 hour is sufficient:

DELETE FROM seen_jtis WHERE created_at < datetime('now', '-1 hour')

Body Digest Verification

The digest claim is a SHA-256 hex digest of the raw JSON request body. This ties the JWT to the specific request body, preventing an attacker from reusing a valid JWT with a different payload.

Operator Side Verification

// IMPORTANT: Use the raw body string, not re-serialized JSON
// Express middleware to capture raw body:
app.use("/api/callback", express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

// In your verification function:
const expectedDigest = crypto
  .createHash("sha256")
  .update(req.rawBody)  // Use raw bytes, NOT JSON.stringify(req.body)
  .digest("hex");

if (payload.digest !== expectedDigest) {
  return { valid: false, error: "Body digest mismatch" };
}
Use the raw body! You must compute the digest from the exact bytes received over the network, not from JSON.stringify(req.body). Re-serializing may change key order or whitespace, producing a different digest. Use Express's verify callback or equivalent to capture the raw bytes.

Key Rotation

Predictu supports key rotation for operators. When keys are rotated, a new key pair is generated, the old keys are discarded, and all subsequent JWTs are signed with the new key. You must fetch the new public key to continue verifying callbacks.

POST/api/s2s/operators/{operator_id}/keys

This endpoint generates a new key pair and returns the new public key.

// Response from key rotation endpoint
{
  "success": true,
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQE...(new key)...\n-----END PUBLIC KEY-----",
  "rotated_at": "2026-03-19T15:00:00.000Z"
}
Coordinate rotation carefully. During key rotation there is a brief window where in-flight requests signed with the old key may arrive after the new key is active. If verification fails after a rotation, re-fetch the public key and retry verification.

TTL & Clock Tolerance

JWTs have a 30-second TTL (exp = iat + 30). This short lifetime limits the window for replay attacks and ensures requests are processed promptly.

SettingValuePurpose
TTL30 secondsLimits replay attack window.
Recommended clock tolerance15 secondsAccounts for clock drift between Predictu servers and the operator's servers.
Effective acceptance window~45 seconds30s TTL + 15s tolerance. After this, the JWT is rejected.
// jose library: set clockTolerance during verification
const { payload } = await jwtVerify(token, publicKey, {
  issuer: "predictu",
  clockTolerance: 15, // seconds
});

Security Checklist

Use this checklist to verify your JWT implementation is production-ready:

CheckStatusDetail
Signature verificationRequiredVerify every request with the public key. Never skip in production.
Issuer validationRequiredReject JWTs where iss is not "predictu".
Expiration checkRequiredReject expired tokens. Use 15s clock tolerance.
JTI replay protectionRequiredTrack seen JTIs in a database table. Reject duplicates.
Body digest verificationStrongly recommendedCompute SHA-256 of raw body and compare to digest claim.
Subject validationRecommendedVerify sub matches your operator_id.
Method cross-checkRecommendedVerify JWT method claim matches body method field.
HTTPS onlyRequired for productionYour callback URL must use HTTPS. HTTP is only allowed for local development.
Raw body captureRequired for digestCapture raw request bytes for digest computation. Do not re-serialize.
Public key cachingRecommendedCache the public key. Re-fetch on verification failure (key rotation).

Development Bypass Mode

The reference implementation supports a bypass mode for local development. If no public key file is found at predictu-public-key.pem, all JWTs are accepted without cryptographic verification.

Never use bypass mode in production. It is exclusively for local development and testing. In bypass mode, any request with a Bearer token is accepted regardless of signature validity.
// Bypass mode detection (casino-demo/jwt-verifier.js)
async function loadPublicKey() {
  if (publicKey) return publicKey;

  if (!fs.existsSync(PUBLIC_KEY_PATH)) {
    console.warn("[jwt-verifier] No public key file found");
    console.warn("[jwt-verifier] Running in BYPASS mode");
    return null;  // null triggers bypass
  }

  const pem = fs.readFileSync(PUBLIC_KEY_PATH, "utf-8");
  publicKey = await importSPKI(pem, "RS256");
  return publicKey;
}

Predictu uses the jose library (by panva) for all JWT operations. Operators can use any RS256-capable JWT library.

LanguageLibraryNotes
Node.js / TypeScriptjoseUsed by Predictu and the casino demo. Zero dependencies.
PythonPyJWT + cryptographyUse jwt.decode(token, key, algorithms=["RS256"]).
Gogolang-jwt/jwt/v5Use jwt.ParseWithClaims with RSA public key.
Java / Kotlincom.auth0:java-jwtUse JWT.require(Algorithm.RSA256(publicKey)).
PHPfirebase/php-jwtUse JWT::decode($token, new Key($publicKey, 'RS256')).
Rubyjwt gemUse JWT.decode(token, public_key, true, algorithm: 'RS256').
.NET / C#System.IdentityModel.Tokens.JwtUse JwtSecurityTokenHandler with RSA parameters.