Security is paramount in CoinPay as we handle cryptocurrency transactions and private keys. This document outlines our security architecture, best practices, and threat mitigation strategies.
- Defense in Depth: Multiple layers of security controls
- Least Privilege: Minimal access rights for users and services
- Zero Trust: Verify every request, never assume trust
- Encryption Everywhere: Data encrypted at rest and in transit
- Audit Everything: Comprehensive logging and monitoring
- Private keys for forwarding addresses
- Merchant credentials and API keys
- Customer payment information
- Platform fee wallet private keys
- Database access credentials
- Private key theft or exposure
- Unauthorized access to merchant accounts
- Man-in-the-middle attacks
- SQL injection and XSS attacks
- Replay attacks on blockchain transactions
- Webhook spoofing
- DDoS attacks
System HD wallet mnemonics can be generated offline using the included helper script:
# Run from the repo root — no npm packages required
node scripts/gen-mnemonic.mjsRun this once per SYSTEM_MNEMONIC_* variable and store the result in your secrets manager. Never reuse the same phrase across chains. The script uses Node.js built-in crypto for entropy and the official BIP39 wordlist — no external dependencies.
In application code, keys are derived from environment-stored mnemonics at runtime:
// HD Wallet derivation using BIP39/BIP44
import { HDKey } from '@scure/bip32';
import { mnemonicToSeed } from '@scure/bip39';
// Derive keys for each blockchain from env-stored mnemonic
const seed = await mnemonicToSeed(process.env.SYSTEM_MNEMONIC_ETH!);
const hdkey = HDKey.fromMasterSeed(seed);
// Bitcoin: m/44'/0'/0'/0/index
// Ethereum: m/44'/60'/0'/0/index
// Solana: m/44'/501'/0'/0/index- Encryption: AES-256-GCM with authenticated encryption
- Key Derivation: PBKDF2 with 100,000 iterations
- Salt: Unique per key, stored with ciphertext
- Master Key: Stored in environment variable, never in database
import crypto from 'crypto';
function encryptPrivateKey(privateKey: string, masterKey: string): string {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
// Derive encryption key from master key
const key = crypto.pbkdf2Sync(masterKey, salt, 100000, 32, 'sha256');
// Encrypt with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Return: salt:iv:authTag:ciphertext
return `${salt.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
function decryptPrivateKey(encryptedKey: string, masterKey: string): string {
const [saltHex, ivHex, authTagHex, encrypted] = encryptedKey.split(':');
const salt = Buffer.from(saltHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
// Derive decryption key
const key = crypto.pbkdf2Sync(masterKey, salt, 100000, 32, 'sha256');
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}- Rotate master encryption key every 90 days
- Re-encrypt all private keys with new master key
- Keep old key for 30 days for recovery
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}- Minimum 12 characters
- Must include: uppercase, lowercase, number, special character
- Cannot contain email or username
- Check against common password lists
- Enforce password history (last 5 passwords)
import jwt from 'jsonwebtoken';
interface TokenPayload {
merchantId: string;
email: string;
iat: number;
exp: number;
}
function generateToken(merchantId: string, email: string): string {
return jwt.sign(
{ merchantId, email },
process.env.JWT_SECRET!,
{
expiresIn: '24h',
issuer: 'coinpayportal.com',
audience: 'coinpayportal-api'
}
);
}
function verifyToken(token: string): TokenPayload {
return jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'coinpayportal.com',
audience: 'coinpayportal-api'
}) as TokenPayload;
}This application uses Next.js 16. Its runtime entry point is src/proxy.ts,
beside src/app. Keep crawler payment checks, site-wide throttling, explorer
allowances, security headers, CORS and referral tracking in that entry point;
do not add a separate root middleware.ts. Referral cookies are set only after
all guards allow the request. Gateway responses, including HTTP 200 payment
receipts and sales pages, must retain their body and status.
Verify the entry point with real HTTP requests as well as direct function tests:
pnpm exec vitest run src/proxy.test.ts src/proxy-entry.test.ts src/proxy.runtime.test.ts src/lib/explorer-watch.test.tsThe HTTP suite copies the repository's interception files into an isolated Next.js app with local test routes. It verifies framework discovery, crawler refusals, explorer and site-wide limits, headers and referral cookies without calling blockchain providers or requiring payment credentials.
// Per IP address
const ipRateLimit = {
windowMs: 60 * 1000, // 1 minute
max: 100 // 100 requests per minute
};
// Per authenticated user
const userRateLimit = {
windowMs: 60 * 60 * 1000, // 1 hour
max: 1000 // 1000 requests per hour
};
// Sensitive endpoints (login, register)
const authRateLimit = {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5 // 5 attempts per 15 minutes
};const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
};import { z } from 'zod';
const createPaymentSchema = z.object({
businessId: z.string().uuid(),
amount: z.number().positive().max(1000000),
currency: z.enum(['USD', 'EUR', 'GBP']),
blockchain: z.enum(['btc', 'bch', 'eth', 'matic', 'sol', 'usdc_eth', 'usdc_matic', 'usdc_sol']),
merchantWalletAddress: z.string().regex(/^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|[a-zA-Z0-9]{32,44})$/),
metadata: z.record(z.any()).optional()
});
// Validate request
const validatedData = createPaymentSchema.parse(requestBody);Using Supabase with parameterized queries:
// GOOD: Parameterized query
const { data, error } = await supabase
.from('payments')
.select('*')
.eq('business_id', businessId)
.eq('status', status);
// BAD: String concatenation (NEVER DO THIS)
// const query = `SELECT * FROM payments WHERE business_id = '${businessId}'`;import DOMPurify from 'isomorphic-dompurify';
function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: [], // No HTML tags allowed
ALLOWED_ATTR: []
});
}
// Sanitize all user inputs
const sanitizedName = sanitizeInput(req.body.name);// Prevent replay attacks by tracking nonces
class NonceManager {
private nonces: Map<string, number> = new Map();
async getNextNonce(address: string): Promise<number> {
const currentNonce = await provider.getTransactionCount(address, 'pending');
const cachedNonce = this.nonces.get(address) || 0;
const nonce = Math.max(currentNonce, cachedNonce);
this.nonces.set(address, nonce + 1);
return nonce;
}
}const MAX_GAS_PRICE = ethers.parseUnits('500', 'gwei'); // 500 gwei max
async function sendTransaction(tx: Transaction) {
const gasPrice = await provider.getFeeData();
if (gasPrice.gasPrice && gasPrice.gasPrice > MAX_GAS_PRICE) {
throw new Error('Gas price too high, transaction aborted');
}
return wallet.sendTransaction(tx);
}const CONFIRMATION_REQUIREMENTS = {
btc: 3,
bch: 6,
eth: 12,
pol: 128,
sol: 32
};
async function waitForConfirmations(
txHash: string,
blockchain: string
): Promise<boolean> {
const required = CONFIRMATION_REQUIREMENTS[blockchain];
let confirmations = 0;
while (confirmations < required) {
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt) {
await sleep(5000);
continue;
}
const currentBlock = await provider.getBlockNumber();
confirmations = currentBlock - receipt.blockNumber + 1;
if (confirmations < required) {
await sleep(10000);
}
}
return true;
}import { isAddress } from 'ethers';
import * as bitcoin from 'bitcoinjs-lib';
import bs58check from 'bs58check';
function validateAddress(address: string, blockchain: string): boolean {
switch (blockchain) {
case 'eth':
case 'matic':
case 'usdc_eth':
case 'usdc_matic':
return isAddress(address);
case 'btc':
case 'bch':
try {
bitcoin.address.toOutputScript(address);
return true;
} catch {
return false;
}
case 'sol':
case 'usdc_sol':
try {
const decoded = bs58check.decode(address);
return decoded.length === 32;
} catch {
return false;
}
default:
return false;
}
}import crypto from 'crypto';
function generateWebhookSignature(
payload: object,
secret: string
): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(payload));
return hmac.digest('hex');
}
function verifyWebhookSignature(
payload: object,
signature: string,
secret: string
): boolean {
const expectedSignature = generateWebhookSignature(payload, secret);
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}async function deliverWebhook(
url: string,
payload: object,
secret: string
): Promise<void> {
const signature = generateWebhookSignature(payload, secret);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CoinPay-Signature': signature,
'X-CoinPay-Timestamp': Date.now().toString(),
'User-Agent': 'CoinPay-Webhook/1.0'
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
throw new Error(`Webhook delivery failed: ${response.status}`);
}
}-- Enable RLS on all tables
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
ALTER TABLE businesses ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- Merchants can only see their own data
CREATE POLICY "Merchants view own data"
ON merchants FOR SELECT
USING (auth.uid() = id);
-- Merchants can only access their own businesses
CREATE POLICY "Merchants view own businesses"
ON businesses FOR SELECT
USING (merchant_id = auth.uid());
-- Merchants can only see payments for their businesses
CREATE POLICY "Merchants view own payments"
ON payments FOR SELECT
USING (
business_id IN (
SELECT id FROM businesses WHERE merchant_id = auth.uid()
)
);// Use connection pooling
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
db: {
schema: 'public'
},
auth: {
autoRefreshToken: true,
persistSession: false
},
global: {
headers: {
'x-application-name': 'coinpayportal'
}
}
}
);# .env.example (NEVER commit actual .env)
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Encryption (Generate with: openssl rand -hex 32)
ENCRYPTION_KEY=your-32-byte-hex-key
# JWT (Generate with: openssl rand -base64 64)
JWT_SECRET=your-jwt-secret
# RPC Providers
BITCOIN_RPC_URL=https://your-bitcoin-rpc
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
# Platform Fee Wallets (NEVER commit these)
PLATFORM_FEE_WALLET_BTC=your-btc-address
PLATFORM_FEE_WALLET_ETH=your-eth-address
PLATFORM_FEE_WALLET_POL=your-pol-address
PLATFORM_FEE_WALLET_SOL=your-sol-address
# System Wallets (Required)
# Generate each phrase with: node scripts/gen-mnemonic.mjs
# Use a unique phrase per chain — NEVER reuse.
SYSTEM_MNEMONIC_BTC="your twelve word bip39 mnemonic phrase goes here"
SYSTEM_MNEMONIC_ETH="your twelve word bip39 mnemonic phrase goes here"
SYSTEM_MNEMONIC_POL="your twelve word bip39 mnemonic phrase goes here"
SYSTEM_MNEMONIC_SOL="your twelve word bip39 mnemonic phrase goes here"
# Tatum API
TATUM_API_KEY=your-tatum-api-key
# Webhook
WEBHOOK_SIGNING_SECRET=your-webhook-secret
# CORS
ALLOWED_ORIGINS=https://yourdomain.com,https://app.yourdomain.comFor production, use a secret management service:
// AWS Secrets Manager example
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
async function getSecret(secretName: string): Promise<string> {
const client = new SecretsManagerClient({ region: 'us-east-1' });
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
);
return response.SecretString!;
}
// Use in production
const encryptionKey = process.env.NODE_ENV === 'production'
? await getSecret('coinpayportal/encryption-key')
: process.env.ENCRYPTION_KEY!;// Log security events
interface SecurityEvent {
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'key_access';
severity: 'low' | 'medium' | 'high' | 'critical';
merchantId?: string;
ip: string;
details: object;
timestamp: Date;
}
async function logSecurityEvent(event: SecurityEvent): Promise<void> {
await supabase.from('security_logs').insert(event);
if (event.severity === 'critical') {
// Alert security team
await sendAlert(event);
}
}- Detection: Automated monitoring alerts
- Containment: Disable affected accounts/keys
- Investigation: Review logs and determine scope
- Eradication: Remove threat and patch vulnerabilities
- Recovery: Restore services and rotate credentials
- Post-Incident: Document and improve processes
- All secrets in environment variables
- Input validation on all endpoints
- Parameterized database queries
- HTTPS only in production
- CORS properly configured
- Rate limiting implemented
- Error messages don't leak sensitive info
- Environment variables secured
- Database backups enabled
- SSL/TLS certificates valid
- Firewall rules configured
- Monitoring and alerting active
- Incident response plan documented
- Regular security audits
- Dependency updates automated
- Key rotation schedule followed
- Access logs reviewed
- Penetration testing performed
- Security training completed
- GDPR compliance for EU customers
- PCI DSS not required (non-custodial)
- SOC 2 Type II certification (future)
audit_log is an append-only table: service_role holds INSERT and SELECT and
is granted neither UPDATE nor DELETE, so an event cannot be rewritten by the
credential that wrote it. Records carry an actor (who), a subject (what) and an
action, because "payment 123 changed" without an actor is an event log rather
than an audit trail. detail is redacted on write — any field whose name looks
key-bearing is replaced before it is stored.
Writing an audit record never fails the operation it describes; a failure is logged loudly instead. This is a forensic record, not a control.
Currently recorded:
- Subscription activation (which plan, for which merchant, against which payment)
- Payout wallet changes (where a merchant's money is sent)
- Key release (Boltz refund/claim recovery — the fact, never the key)
- DID rebinding by a platform
Not yet covered, and worth extending to: general payment state transitions, API
access with timestamps, and failed authentication attempts. This section
previously described all of that as already existing, which is what AUD-01
found: there was no audit infrastructure at all. Remaining coverage is tracked
in /TODO-vulns.md.
- Security Issues: security@coinpayportal.com
- Bug Bounty: https://coinpayportal.com/security/bounty
- PGP Key: Available at https://coinpayportal.com/security/pgp