Security
Request signing and verification
Phoenix signs every webhook request with RSA-SHA256. You must verify the signature before processing any request. Requests without a valid signature should be rejected immediately.
How It Works
- Phoenix serializes the request body to a compact JSON string (UTF-8, no extra whitespace)
- Signs it with RSA-SHA256 (PKCS1v15 padding, SHA-256 digest)
- Encodes the signature as base64url with no padding (RFC 4648 section 5, no
=characters) - Sends it in the
signatureheader
Verifying Signatures
You receive Phoenix's public key (PEM format) during onboarding. Store it as an environment variable or in secure config. Use it to verify every incoming webhook request.
The critical rule: verify the signature against the raw request body bytes, not a re-serialized version. If you parse the JSON first and then re-serialize it, key ordering or whitespace differences will break verification.
Node.js (Express)
const crypto = require('crypto');
// Preserve raw body - add this BEFORE other body parsers
app.use('/webhook', express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
function verifySignature(req, res, next) {
const signature = req.headers['signature'];
if (!signature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// base64url to standard base64 for Node's crypto
const base64 = signature.replace(/-/g, '+').replace(/_/g, '/');
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(req.rawBody);
if (!verifier.verify(process.env.PHOENIX_PUBLIC_KEY, base64, 'base64')) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
// Apply to all webhook routes
app.use('/withdraw', verifySignature);
app.use('/deposit', verifySignature);
app.use('/deposit-batch', verifySignature);
app.use('/rollback', verifySignature);
app.use('/player-balance', verifySignature);Python (FastAPI)
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import Request, HTTPException
PHOENIX_PUBLIC_KEY = serialization.load_pem_public_key(
open("phoenix_public_key.pem", "rb").read()
)
async def verify_signature(request: Request):
signature = request.headers.get("signature")
if not signature:
raise HTTPException(401, "Invalid signature")
raw_body = await request.body()
try:
padded = signature + "=" * (-len(signature) % 4)
sig_bytes = base64.urlsafe_b64decode(padded)
PHOENIX_PUBLIC_KEY.verify(sig_bytes, raw_body, padding.PKCS1v15(), hashes.SHA256())
except Exception:
raise HTTPException(401, "Invalid signature")Java (Spring Boot)
import java.security.*;
import java.util.Base64;
public boolean verifySignature(byte[] rawBody, String signatureHeader, PublicKey publicKey)
throws Exception {
byte[] sigBytes = Base64.getUrlDecoder().decode(signatureHeader);
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(rawBody);
return sig.verify(sigBytes);
}Common Mistakes
Re-serializing the body before verification. This is the #1 cause of signature failures. Different JSON serializers produce different output (key order, spacing). Always verify against the raw bytes you received.
Using standard base64 instead of base64url. The signature uses URL-safe base64 (- and _ instead of + and /) with no = padding. Most languages have a urlsafe or URL_SAFE variant in their base64 library.
Not rejecting unsigned requests. Every endpoint must reject requests without a valid signature - including /player-balance and /rollback, not just /withdraw.