Casino Integration — Server-to-Server Authentication

This document is the contract for casino engineers integrating their backend with the prediction-markets platform.

It describes how we authenticate to you: every server-to-server call the prediction-markets platform makes to your casino's API (e.g. the upcoming wallet debit/credit calls) carries a signed token that you verify. This is the outbound direction and is separate from how your players authenticate to us.

For a plain-language walkthrough of the flow (who does what, and why), see private-key-jwt-explained.md. This document is the implementation contract.

Two independent directions — don't confuse them.

Direction Who signs Algorithm Where the key lives
Inbound — player → us Your backend HS256, shared hmacSecret Secret you hold, given at onboarding
Outbound — us → your backend (this doc) Our backend ES256, our private key You only ever hold our public key

The inbound shared secret is never reused for outbound calls.


1. The model: private_key_jwt

On every server-to-server request we send you, we attach a short-lived JWT in the Authorization header:

Authorization: Bearer <JWT>

The JWT is signed with ES256 (ECDSA on the P-256 curve) using our private key. You verify it with our public key, which we publish as a JSON Web Key Set (JWKS). You never hold a secret of ours — a leak of your database cannot be used to impersonate us, and nothing is shared between casinos.

There is no token-exchange step: the JWT is itself the credential, presented fresh on each call. You do not need to run an OAuth token endpoint.


2. Token format

Header

{
  "alg": "ES256",
  "kid": "gIeicZJ1yIkQbjShOz7L3N6FTnFKy8ClCn54F__aFQg",
  "typ": "JWT"
}

Claims

Claim Example Meaning / how you validate it
iss https://api.prediction-markets.example Our identity. Must equal the issuer we give you at onboarding.
sub https://api.prediction-markets.example Same as iss — the token asserts our own identity, not a delegated user.
aud casino-acme Your assigned audience (see §6). Must equal the value we agreed for you. Reject tokens addressed to anyone else.
iat 1750000000 Issued-at (epoch seconds).
exp 1750000060 Expiry (epoch seconds). Tokens live ~60s. Reject if expired (allow small skew, §5).
jti b1c2… (UUID) Unique token id. Optional replay defense (§5).

3. The JWKS endpoint

Fetch our public keys from:

GET https://<our-base-url>/api/.well-known/jwks.json

Example response:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "X4OXjuCQsXv9svic5dRxtHJmuBKConxegerfg7N7fis",
      "y": "jrurAogjqwsVfd2NoMfYOA8PNHSwXrcHQDpDqiVCjVQ",
      "kid": "gIeicZJ1yIkQbjShOz7L3N6FTnFKy8ClCn54F__aFQg",
      "use": "sig",
      "alg": "ES256"
    }
  ]
}

Caching & rotation. The endpoint sends Cache-Control: max-age=300. Cache the key set and index it by kid. When you receive a token whose kid is not in your cache, re-fetch the JWKS once and retry — this is how you pick up a key rotation with no coordination. During a rotation we publish the new key alongside the retiring one, so tokens signed by either verify throughout the overlap.

Use a JWKS client that handles caching and the unknown-kid re-fetch for you (e.g. jwks-rsa / jose's createRemoteJWKSet in Node, PyJWKClient in Python). Don't fetch the JWKS on every request.


4. Verification algorithm

For every inbound server-to-server request from us:

  1. Extract the bearer token from the Authorization header.
  2. Decode the header; confirm alg === "ES256". Reject anything else.
  3. Select the public key from the JWKS by kid (re-fetch on unknown kid).
  4. Verify the ES256 signature against that key.
  5. Validate claims:
    • iss equals the issuer we gave you.
    • aud equals your assigned audience.
    • exp is in the future and iat is not in the future, allowing ±60s skew.
  6. (Optional) Reject if you have seen jti before within its lifetime.

If any step fails, respond 401 Unauthorized and do not process the request.

Node.js example (jose)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://<our-base-url>/api/.well-known/jwks.json'),
);

export async function verifyPlatformToken(authorizationHeader: string) {
  const token = authorizationHeader.replace(/^Bearer\s+/i, '');
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://api.prediction-markets.example', // the iss we gave you
    audience: 'casino-acme',                          // your assigned aud
    algorithms: ['ES256'],
    clockTolerance: 60, // seconds
  });
  return payload; // safe to act on the request
}

Python example (PyJWT)

import jwt
from jwt import PyJWKClient

jwks = PyJWKClient("https://<our-base-url>/api/.well-known/jwks.json")

def verify_platform_token(authorization_header: str) -> dict:
    token = authorization_header.removeprefix("Bearer ").strip()
    signing_key = jwks.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["ES256"],
        issuer="https://api.prediction-markets.example",
        audience="casino-acme",
        leeway=60,
    )

5. Clock skew & replay


6. What we exchange at onboarding

Item Provided by Notes
JWKS URL Us https://<our-base-url>/api/.well-known/jwks.json
Issuer (iss) Us The exact string you must require.
Audience (aud) Agreed The identifier we stamp on tokens addressed to you. Defaults to your tenant slug; tell us if you need a specific value (e.g. your API's base URL or a client id).
Your API base URL You Where we send wallet calls (configured in the next phase).

Static public key fallback

If you cannot fetch and cache a JWKS, we can instead hand you a single pinned public key (PEM) out of band. Verification is identical except you skip the JWKS fetch and kid selection. The trade-off: key rotation then requires us to send you the new key and you to deploy it, rather than it propagating automatically. JWKS is strongly preferred.


7. Future authentication modes

The platform models an auth mode per casino. Today only PRIVATE_KEY_JWT (this document) is implemented. Two more are reserved for casinos with specific needs and slot in behind the same onboarding process:

If neither applies, no action is needed: PRIVATE_KEY_JWT is the default.