Technical Reference
HMAC Verification Spec
How to verify that requests forwarded to your origin in proxy mode were signed by PayFence.
Overview
When PayFence operates in proxy mode, every request forwarded to your origin carries an HMAC-SHA256 signature. Your origin must verify this signature before processing the request. If the signature is missing, expired, or invalid, your origin should reject the request with a 401 Unauthorized response.
The shared secret used for signing is available in your dashboard under site settings. It starts with whsec_ and should be stored securely as an environment variable on your server. Never expose it in client-side code or version control.
Request Headers
PayFence adds the following headers to every proxied request:
Canonical String Format
The signature is computed over a canonical string built from five components separated by newline characters (\n):
method\npath\ntimestamp\nrequestId\nbodyHashGET, POST, PUT, etc./v1/flightsX-PayFence-Timestamp header (Unix epoch seconds).X-PayFence-Request-Id header.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Algorithm
The signature algorithm is HMAC-SHA256. The HMAC is keyed with your shared secret (whsec_...) and computed over the canonical string. The resulting hex digest is prefixed with v1= and sent in the X-PayFence-Signature header.
X-PayFence-Signature: v1={hex(HMAC-SHA256(secret, canonical))}Replay Protection
The timestamp in the canonical string prevents replay attacks. Your origin should reject any request where the timestamp is more than 5 minutes (300 seconds) from the current server time. This window accounts for reasonable clock skew between PayFence servers and your origin.
Additionally, the X-PayFence-Request-Id is included in the canonical string to ensure that each signature is unique per request, even if two requests have the same method, path, and body within the same timestamp.
Verification Steps
Follow these steps in your origin server to verify each request:
- 1
Extract the X-PayFence-Signature, X-PayFence-Timestamp, and X-PayFence-Request-Id headers. Reject the request if any are missing.
- 2
Check that the timestamp is within 5 minutes (300 seconds) of the current server time. Reject if expired.
- 3
Compute the SHA-256 hash of the raw request body (or empty string for bodiless requests).
- 4
Build the canonical string: method\npath\ntimestamp\nrequestId\nbodyHash
- 5
Compute the HMAC-SHA256 of the canonical string using your shared secret.
- 6
Compare the computed signature with the signature from the header using a timing-safe comparison function. Reject if they do not match.
Important: Always use a timing-safe comparison function (e.g., crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hmac.Equal in Go) to prevent timing attacks.
Node.js (Express)
Complete middleware using the built-in crypto module. Make sure your Express app preserves the raw body (e.g., via express.json({ verify: (req, _res, buf) => req.rawBody = buf })).
const crypto = require("crypto");
/**
* Express middleware to verify PayFence HMAC signatures.
* Expects raw body to be available as req.rawBody (Buffer).
*/
function verifyPayFence(secret) {
return (req, res, next) => {
const signature = req.headers["x-payfence-signature"];
const timestamp = req.headers["x-payfence-timestamp"];
const requestId = req.headers["x-payfence-request-id"];
if (!signature || !timestamp || !requestId) {
return res.status(401).json({ error: "Missing PayFence headers" });
}
// 1. Check replay window (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return res.status(401).json({ error: "Request timestamp expired" });
}
// 2. Build the canonical string
const bodyHash = crypto
.createHash("sha256")
.update(req.rawBody || "")
.digest("hex");
const canonical = [
req.method.toUpperCase(),
req.path,
timestamp,
requestId,
bodyHash,
].join("\n");
// 3. Compute expected HMAC
const expected = crypto
.createHmac("sha256", secret)
.update(canonical)
.digest("hex");
// 4. Timing-safe comparison
const sig = signature.replace("v1=", "");
if (
sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).json({ error: "Invalid signature" });
}
next();
};
}
// Usage with Express
// app.use(verifyPayFence(process.env.PAYFENCE_WEBHOOK_SECRET));Python (Flask)
Decorator using the standard library hmac and hashlib modules.
import hashlib
import hmac
import time
from functools import wraps
from flask import request, jsonify
def verify_payfence(secret: str):
"""Flask decorator to verify PayFence HMAC signatures."""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
signature = request.headers.get("X-PayFence-Signature", "")
timestamp = request.headers.get("X-PayFence-Timestamp", "")
request_id = request.headers.get("X-PayFence-Request-Id", "")
if not signature or not timestamp or not request_id:
return jsonify({"error": "Missing PayFence headers"}), 401
# 1. Check replay window (5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return jsonify({"error": "Request timestamp expired"}), 401
# 2. Build the canonical string
body = request.get_data(as_text=False)
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join([
request.method.upper(),
request.path,
timestamp,
request_id,
body_hash,
])
# 3. Compute expected HMAC
expected = hmac.new(
secret.encode(),
canonical.encode(),
hashlib.sha256,
).hexdigest()
# 4. Timing-safe comparison
sig = signature.removeprefix("v1=")
if not hmac.compare_digest(sig, expected):
return jsonify({"error": "Invalid signature"}), 401
return f(*args, **kwargs)
return wrapper
return decorator
# Usage with Flask
# @app.route("/api/resource")
# @verify_payfence(os.environ["PAYFENCE_WEBHOOK_SECRET"])
# def my_endpoint():
# return jsonify({"ok": True})Go
Standard net/http middleware using crypto/hmac and crypto/sha256.
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// VerifyPayFence returns HTTP middleware that validates PayFence
// HMAC-SHA256 request signatures.
func VerifyPayFence(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-PayFence-Signature")
timestamp := r.Header.Get("X-PayFence-Timestamp")
requestID := r.Header.Get("X-PayFence-Request-Id")
if signature == "" || timestamp == "" || requestID == "" {
http.Error(w, "Missing PayFence headers", http.StatusUnauthorized)
return
}
// 1. Check replay window (5 minutes)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
http.Error(w, "Request timestamp expired", http.StatusUnauthorized)
return
}
// 2. Build the canonical string
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
canonical := fmt.Sprintf("%s\n%s\n%s\n%s\n%s",
r.Method, r.URL.Path, timestamp, requestID, bodyHash)
// 3. Compute expected HMAC
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
// 4. Timing-safe comparison
sig := strings.TrimPrefix(signature, "v1=")
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage:
// mux.Handle("/api/", VerifyPayFence(os.Getenv("PAYFENCE_WEBHOOK_SECRET"))(handler))Example Signed Request
Here is what a complete signed request looks like when PayFence forwards it to your origin:
GET /v1/flights HTTP/1.1
Host: your-origin.example.com
Authorization: Bearer tok_abc123
X-PayFence-Signature: v1=a3f1b9c7e2d4f6a8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
X-PayFence-Timestamp: 1706745600
X-PayFence-Request-Id: req_8f2a1b3c4d5e
X-PayFence-Site: travel-apiThe canonical string for this request would be:
GET
/v1/flights
1706745600
req_8f2a1b3c4d5e
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Note: The body hash above is the SHA-256 of an empty string, since GET requests have no body.
Troubleshooting
Signature mismatch on POST requests
Ensure you are hashing the raw, unparsed request body. If your framework parses JSON before your verification middleware runs, the body bytes may differ from what PayFence signed. Configure your framework to preserve the raw body.
Timestamp always expired
Check that your server clock is synchronized with NTP. The 5-minute window assumes accurate clocks on both sides. Large clock drift will cause all requests to fail.
Wrong secret
The shared secret is specific to each site. If you operate multiple PayFence sites on the same origin, make sure you are using the correct secret for each site. You can check the X-PayFence-Site header to route to the right secret.