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 JWT Signing Works
digest claim in the JWT. This binds the JWT to the specific request body.iss, sub,iat, exp, jti) plus custom claims (method,digest).Authorization: Bearerheader and the JSON body in the POST body. 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:
| Claim | Type | Standard | Description |
|---|---|---|---|
iss | string | Yes | Issuer. Always "predictu". Verify this matches exactly. |
sub | string | Yes | Subject. The operator_id this JWT was signed for. Verify this matches your operator ID. |
iat | number | Yes | Issued At. Unix timestamp (seconds) when the JWT was created. |
exp | number | Yes | Expiration. Unix timestamp. Set to iat + 30 (30-second TTL). Reject expired tokens. |
jti | string | Yes | JWT ID. Set to the request_id (UUID). Use this for replay protection. |
method | string | Custom | The S2S method being called (e.g. BET_MAKE). Cross-reference with the body's method field. |
digest | string | Custom | SHA-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 " prefixKey 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.
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.
/api/s2s/operators/{operator_id}/public-keyResponse
{
"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:
Authorization: Bearer {token} header.iss must equal "predictu".exp must be in the future. The TTL is 30 seconds, so allow a small clock tolerance (15 seconds recommended).jti in your seen_jtistable. If it exists, this is a replayed token - reject it. If it's new, record it.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);
}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" };
}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.
/api/s2s/operators/{operator_id}/keysThis 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"
}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.
| Setting | Value | Purpose |
|---|---|---|
| TTL | 30 seconds | Limits replay attack window. |
| Recommended clock tolerance | 15 seconds | Accounts for clock drift between Predictu servers and the operator's servers. |
| Effective acceptance window | ~45 seconds | 30s 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:
| Check | Status | Detail |
|---|---|---|
| Signature verification | Required | Verify every request with the public key. Never skip in production. |
| Issuer validation | Required | Reject JWTs where iss is not "predictu". |
| Expiration check | Required | Reject expired tokens. Use 15s clock tolerance. |
| JTI replay protection | Required | Track seen JTIs in a database table. Reject duplicates. |
| Body digest verification | Strongly recommended | Compute SHA-256 of raw body and compare to digest claim. |
| Subject validation | Recommended | Verify sub matches your operator_id. |
| Method cross-check | Recommended | Verify JWT method claim matches body method field. |
| HTTPS only | Required for production | Your callback URL must use HTTPS. HTTP is only allowed for local development. |
| Raw body capture | Required for digest | Capture raw request bytes for digest computation. Do not re-serialize. |
| Public key caching | Recommended | Cache 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.
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;
}Recommended Libraries
Predictu uses the jose library (by panva) for all JWT operations. Operators can use any RS256-capable JWT library.
| Language | Library | Notes |
|---|---|---|
| Node.js / TypeScript | jose | Used by Predictu and the casino demo. Zero dependencies. |
| Python | PyJWT + cryptography | Use jwt.decode(token, key, algorithms=["RS256"]). |
| Go | golang-jwt/jwt/v5 | Use jwt.ParseWithClaims with RSA public key. |
| Java / Kotlin | com.auth0:java-jwt | Use JWT.require(Algorithm.RSA256(publicKey)). |
| PHP | firebase/php-jwt | Use JWT::decode($token, new Key($publicKey, 'RS256')). |
| Ruby | jwt gem | Use JWT.decode(token, public_key, true, algorithm: 'RS256'). |
| .NET / C# | System.IdentityModel.Tokens.Jwt | Use JwtSecurityTokenHandler with RSA parameters. |
