Integrating OFFER-HUB into your marketplace means handling real USDC transactions on a public blockchain. A misconfiguration can expose user funds or allow spoofed payment events to reach your system. This guide gives you concrete, OFFER-HUB-specific security practices — not generic web security advice.
This guide focuses on threats specific to payment orchestration and blockchain escrow. Read it before going to production.
API Key Management
OFFER-HUB API keys follow the format ohk_live_xxx (production) and ohk_test_xxx (sandbox). A leaked live key gives full control over all payment operations — including releasing escrow funds and initiating withdrawals.
API keys must appear in source code, client-side bundles, or version control. Use environment variables, and load them server-side only.
# .env — never commit this file
OFFER_HUB_API_KEY=ohk_live_abc123...
OFFER_HUB_WEBHOOK_SECRET=whsec_xyz789...
// ✅ Read from the environment at runtime — server-side only
const apiKey = process.env.OFFER_HUB_API_KEY;
if (!apiKey) throw new Error('OFFER_HUB_API_KEY is not set');
// ❌ Never hardcode keys in source code
const apiKey = 'ohk_live_abc123...'; // Will end up in git history
// ❌ Never embed keys in client-side bundles
const response = await fetch('/api/orders', {
headers: { Authorization: `Bearer ohk_live_abc123...` },
});
API keys must travel server-to-server. Your marketplace backend calls OFFER-HUB; your frontend never does.
❌ Browser → OFFER-HUB API (exposes key via DevTools)
✅ Browser → Your Backend → OFFER-HUB API
Rotate your API key:
- if a team member with access leaves
- if you suspect exposure (public repos, logs, error messages)
- as routine practice in production
Use the minimum scope each service needs:
| Service | Required Scope |
|---|
| Read-only dashboard | read |
| Order creation service | write |
| Dispute resolution admin | support |
Webhook Signature Validation
OFFER-HUB signs every webhook payload with an HMAC-SHA256 signature. Without validation, any actor who knows your webhook URL can send spoofed events — such as a fake escrow.released — and trick your system into delivering services without payment.
Each webhook request carries:
X-OfferHub-Signature: sha256=<hex_digest>
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string,
secret: string,
): boolean {
const expected = `sha256=${createHmac('sha256', secret)
.update(rawBody)
.digest('hex')}`;
try {
// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
} catch {
return false; // Different lengths — always invalid
}
}
// Express example — use raw body parser
app.post('/webhooks/offerhub', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-offerhub-signature'] as string;
if (!verifyWebhookSignature(req.body, sig, process.env.OFFER_HUB_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Safe to process
});
Parse the before JSON.parse — the signature covers the raw bytes, not the parsed object. Using express.json() instead of express.raw() will always fail validation.
A valid signature doesn't mean the event is fresh. Store processed event IDs in Redis to prevent duplicate processing:
const FIVE_MINUTES_MS = 5 * 60 * 1000;
async function processWebhook(event: any) {
// Check timestamp freshness
const age = Math.abs(Date.now() - new Date(event.timestamp).getTime());
if (age > FIVE_MINUTES_MS) throw new Error('Stale event rejected');
// Deduplicate using event ID
const key = `webhook:processed:${event.id}`;
const alreadyProcessed = await redis.exists(key);
if (alreadyProcessed) return { status: 'duplicate' };
await redis.setex(key, 3600, '1');
// Process the event
}
Wallet Private Key Management
OFFER-HUB manages Stellar invisible wallets for your users. Private keys are encrypted with AES-256-GCM at rest. As an integrator, you call the Orchestrator API —
What the Orchestrator Handles
- Generating Stellar keypairs per user
- Encrypting and storing private keys (never in plaintext)
- Decrypting keys during transaction signing
- Zeroing the in-memory key immediately after signing
// ❌ Never log objects that may contain key fields
console.log('Wallet:', wallet); // Strip sensitive fields first
// ❌ Never send private keys to clients
res.json({ secretKey: wallet.secretKey }); // Never
// ❌ Never store unencrypted keys
await db.users.update({ stellarSecretKey: rawKey }); // Must be encrypted
The encryption key protecting all wallet private keys must be treated as your most sensitive secret:
# Generate with high entropy — never a human-readable string
WALLET_ENCRYPTION_KEY=$(openssl rand -hex 32)
- Store it in an HSM or managed secrets service (AWS Secrets Manager, HashiCorp Vault)
- store it in your codebase,
.env files in version control, or database
- Back it up securely — losing this key means losing access to all user wallets permanently
The platform wallet (used to sign dispute resolutions) is the most sensitive key in the system. Consider an HSM like AWS CloudHSM or Azure Dedicated HSM for production deployments.
Escrow Integration Security
The escrow flow is the highest-risk operation in OFFER-HUB. A bug here can result in funds being released to the wrong party or funds being permanently locked.
Always verify order and escrow state from the API before triggering a resolution. Never rely solely on a webhook event.
async function releaseEscrow(orderId: string, requestedBy: string) {
const order = await offerHubClient.orders.get(orderId);
if (order.status !== 'IN_PROGRESS') {
throw new Error(`Cannot release order in state: ${order.status}`);
}
if (order.buyerId !== requestedBy) {
throw new Error('Only the buyer can release escrow funds');
}
if (!order.escrow || order.escrow.status !== 'FUNDED') {
throw new Error('Escrow is not in FUNDED state');
}
return offerHubClient.orders.release(orderId, { requestedBy });
}
Before calling POST /orders/:id/resolution/release:
| Check | Why |
|---|
Order status is IN_PROGRESS | Prevents double-release |
| Requester is the buyer | Only the buyer approves delivery |
Escrow status is FUNDED | Prevents acting on unfunded escrow |
| Work delivery evidence exists | App-level check: ensure work was submitted |
| No open dispute exists | Cannot release during active dispute |
Protect against double-releases caused by network retries:
// Key must be deterministic per operation — not random per retry
const idempotencyKey = `release:${orderId}:${buyerId}`;
await offerHubClient.orders.release(orderId, {
requestedBy: buyerId,
idempotencyKey,
});
Security Headers and CSP
Configure HTTP security headers to protect your users from XSS attacks that could intercept payment flows.
// next.config.js
const securityHeaders = [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};
A strict CSP prevents injected scripts from stealing session tokens or intercepting payment data:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{NONCE}';
connect-src 'self' https://your-orchestrator.example.com;
frame-ancestors 'none';
upgrade-insecure-requests;
Never use script-src 'unsafe-inline' or connect-src * in a payment context. Restrict connect-src to your Orchestrator's exact domain only.
Blockchain-Specific Attack Vectors
A signed Stellar transaction is resubmitted to process the same payment twice.
Stellar transactions include a sequence number — each transaction can only be processed once. Replaying it returns tx_bad_seq.
Use idempotency keys on all API calls. If a release succeeds but the network drops before you receive the response, retry with the same idempotency key to get the cached result instead of a second release.
An attacker submits a competing transaction before yours to manipulate fund flow.
Stellar uses a FIFO queue within each ledger cycle, not fee-priority auctions like Ethereum. Traditional front-running does not apply.
Enforce strict RBAC on your Orchestrator deployment and enable database audit logging to prevent internal actors from triggering unauthorized releases.
OFFER-HUB's off-chain balance (PostgreSQL) diverges from the on-chain USDC balance (Stellar ledger).
async function reconcileBalance(userId: string) {
const offChain = await offerHubClient.balances.get(userId);
const account = await stellarServer.loadAccount(wallet.publicKey);
const usdcBalance = account.balances.find(b => b.asset_code === 'USDC');
const delta = Math.abs(
parseFloat(usdcBalance?.balance ?? '0') - parseFloat(offChain.available)
);
if (delta > 0.001) {
// Alert: investigate before processing further transactions
alertOpsTeam({ userId, offChain: offChain.available, onChain: usdcBalance?.balance });
}
}
An attacker substitutes a legitimate deposit address with their own Stellar address.
// ✅ Always fetch deposit addresses from the API — never from user-supplied input
const { data } = await offerHubClient.wallet.getDepositAddress(userId);
displayAddress(data.address);
// ❌ Never trust an address from the request body
const depositAddress = req.body.depositAddress; // Attacker-controlled
Next Steps
- Escrow Guide — Smart contract escrow mechanics
- Webhooks Reference — Payload formats and headers
- Self-Hosting Guide — Secure production deployment
- API Reference — Authentication and rate limiting