In a traditional payment system, secrets are vaulted by the processor. In a crypto gateway, you control the hot-wallet private key — and the key IS the money. There is no bank to call, no ACH reversal, no chargeback. A leaked private key is an irreversible loss event. Yet most teams treat key storage as an afterthought: an environment variable here, a Secrets Manager plaintext value there. This post is the signing-key architecture we use at BchainPay and how to replicate it independently.
The threat model#
The attacker has two goals: extract the raw key bytes, or coerce your signing infrastructure into broadcasting a transaction to an address they control.
Common root causes in post-mortems:
- Private key in a
.envfile committed to git at any point in history - Plaintext in Secrets Manager or SSM Parameter Store — the value at rest is encrypted, but a process that retrieves it on boot holds raw bytes in memory, and exception handlers routinely log process state
- Exception logging that serialises a config object containing the key
- Infrastructure compromise — an attacker with host access can read
/proc/<pid>/memor attach a debugger and dump heap - Insider access to a shared credential vault
The signing path is as dangerous as your most permissive trust boundary. If your API server can call a "sign anything" function with no business-rule check, a single XSS or SSRF pivots into total treasury access.
The core rule: the signing key must never exist as a plaintext byte array in any process that also handles user-facing input. Authorised misuse matters as much as raw key theft — secure the key and constrain what callers can request it to sign.
Option 1: AWS KMS (lowest operational overhead)#
AWS KMS supports secp256k1 — the curve used by Ethereum, Polygon, BNB Chain,
TRON, Base and all other EVM-compatible chains — via the ECC_SECG_P256K1 key
spec. Signing happens inside a FIPS 140-2 validated HSM; the private key
material never leaves AWS infrastructure.
Create the key#
aws kms create-key \
--key-spec ECC_SECG_P256K1 \
--key-usage SIGN_VERIFY \
--description "bchainpay hot wallet – polygon"Attach a key policy that grants kms:Sign only to the IAM role of your
dedicated signing service, and kms:GetPublicKey only during provisioning.
No other role or user should be able to call Sign.
Derive the Ethereum address#
KMS returns the public key as a DER-encoded SubjectPublicKeyInfo (SPKI). Use
Node.js's built-in crypto.createPublicKey with JWK re-export to extract the
raw coordinates without a third-party ASN.1 library:
import { createPublicKey } from 'node:crypto';
import { KMSClient, GetPublicKeyCommand } from '@aws-sdk/client-kms';
import { keccak256 } from 'ethers';
async function kmsEthAddress(kms: KMSClient, keyId: string): Promise<string> {
const { PublicKey } = await kms.send(new GetPublicKeyCommand({ KeyId: keyId }));
// Parse DER SPKI and re-export as JWK to get raw x / y coordinates
const nodeKey = createPublicKey({
key: Buffer.from(PublicKey!),
format: 'der',
type: 'spki',
});
const jwk = nodeKey.export({ format: 'jwk' }) as { x: string; y: string };
// Uncompressed EC point = X || Y (64 bytes, no 0x04 prefix)
const raw = Buffer.concat([
Buffer.from(jwk.x, 'base64url'),
Buffer.from(jwk.y, 'base64url'),
]);
// Ethereum address = last 20 bytes of keccak256(raw)
return '0x' + keccak256(raw).slice(-40);
}The JWK x and y fields are base64url-encoded big-endian 32-byte integers
— no DER length quirks, no leading-zero padding ambiguity.
Sign an EVM transaction#
KMS returns a DER-encoded ECDSA signature. You must decode it, normalise s
to low-S (required by EIP-2 since Homestead), and recover the parity bit.
EIP-1559 typed transactions use yParity (0 or 1), not the legacy v = 27/28
convention:
import {
KMSClient,
SignCommand,
SigningAlgorithmSpec,
} from '@aws-sdk/client-kms';
import { Transaction, Signature, keccak256, getBytes } from 'ethers';
const N = BigInt(
'0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
);
function parseDer(der: Buffer): { r: bigint; s: bigint } {
// DER layout: 0x30 <total-len> 0x02 <r-len> <r-bytes> 0x02 <s-len> <s-bytes>
let o = 2;
o++;
const rLen = der[o++];
const r = BigInt('0x' + der.subarray(o, o + rLen).toString('hex'));
o += rLen;
o++;
const sLen = der[o++];
const s = BigInt('0x' + der.subarray(o, o + sLen).toString('hex'));
return { r, s };
}
function pad32(n: bigint): string {
return '0x' + n.toString(16).padStart(64, '0');
}
async function kmsSign(
kms: KMSClient,
keyId: string,
signerAddress: string,
tx: Transaction,
): Promise<string> {
// Compute the typed transaction digest (Keccak-256)
const digest = keccak256(getBytes(tx.unsignedSerialized));
const { Signature: derBytes } = await kms.send(new SignCommand({
KeyId: keyId,
Message: Buffer.from(digest.slice(2), 'hex'),
MessageType: 'DIGEST', // pre-hashed; KMS must NOT re-hash
SigningAlgorithm: SigningAlgorithmSpec.ECDSA_SHA_256,
}));
let { r, s } = parseDer(Buffer.from(derBytes!));
// EIP-2: reject high-S signatures; normalise to low-S
if (s > N / 2n) s = N - s;
// Recover yParity (0 or 1); typed txs do not use 27/28
for (const yParity of [0, 1] as const) {
const sig = Signature.from({ r: pad32(r), s: pad32(s), yParity });
const recovered = sig.recoverPublicKey(digest).computeAddress();
if (recovered.toLowerCase() === signerAddress.toLowerCase()) {
tx.signature = sig;
return tx.serialized;
}
}
throw new Error('KMS sign: neither yParity recovered the expected address');
}Two details that trip up most implementations:
MessageType: 'DIGEST'is mandatory. Without it, KMS attempts to apply SHA-256 to your input before signing. The digest must already be the Keccak-256 of the unsigned serialised transaction — KMS doesn't care what hash function produced it, it just signs the 32 bytes you supply.randsare variable-length ASN.1 INTEGERs. A leading0x00byte is inserted when the high bit is set (to keep the integer positive in DER). TheparseDerfunction above reads the length byte rather than assuming a fixed offset, so it handles 31, 32 or 33-byte values correctly.
Constrain the signing service#
KMS restricts key access. Your application layer must restrict what can be signed. A signing microservice that accepts only structured payout requests, validates destinations against an allowlist, and enforces per-chain daily limits dramatically caps the blast radius of any compromise:
if (dailySignedUsd + amountUsd > DAILY_LIMIT_USD[chain]) {
throw new SigningLimitError('daily limit exceeded');
}
if (amountUsd > HIGH_VALUE_THRESHOLD_USD) {
await requireHumanApproval({ payoutId, amountUsd, destination });
}This signing service must never be reachable from user-facing HTTP handlers. Place it on an internal network segment, authenticated only by mTLS or IAM.
Option 2: MPC threshold signing#
MPC wallet schemes — Fireblocks, Silence Laboratories, and open-source libraries implementing GG20 or CGGMP21 — split the private key across multiple parties so that no single machine ever holds complete key material. Signing requires a quorum (e.g., 2-of-3) to cooperate over a multi-round protocol.
The security gain: an attacker who fully compromises one signing node sees only a key share that is cryptographically useless in isolation.
The trade-offs:
- Signing latency rises because protocol rounds require network trips. For interactive payouts the added time is imperceptible; for high-throughput batch sweeps it is worth benchmarking before committing.
- Vendor key custody is the primary risk with managed MPC providers. If the vendor holds one of the shares, your key security in a catastrophic scenario is contingent on their survival. Verify whether you can export your shares and reconstruct independently.
- Protocol security varies. GG20 and CGGMP21 are proven against malicious parties; some older three-party protocols are not. Ask for the security proof, not just the marketing claim.
MPC is the right choice when a single compromised host must not be sufficient for key extraction. KMS achieves similar guarantees at lower complexity for most payment gateway use cases.
Hot / warm / cold wallet architecture#
┌────────────────────────────────────────────────┐
│ COLD WALLET │
│ Multisig (Gnosis Safe 3-of-5) │
│ Air-gapped hardware signers, manual approval │
│ Holds > 90 % of treasury │
└────────────────────┬───────────────────────────┘
│ weekly auto-sweep
┌────────────────────▼───────────────────────────┐
│ WARM WALLET │
│ KMS or MPC, restricted IAM │
│ Holds 3–7 days of projected payout volume │
└────────────────────┬───────────────────────────┘
│ per-payout, automated
┌────────────────────▼───────────────────────────┐
│ HOT WALLET │
│ KMS, one key per chain, single-purpose │
│ Holds < 24 h of inflow; balance alerted │
└────────────────────────────────────────────────┘
Each tier has a purpose: hot signs automatically at high frequency; warm replenishes hot on a schedule without human involvement; cold is only ever touched by a deliberate, multi-approver action. Never let automated processes reach the cold wallet.
BchainPay's POST /v1/payout_intents API operates at the hot tier. If you
are running self-custody infrastructure layered on top of BchainPay for
deposit monitoring, this is the architecture that should sit behind your own
signing service.
Circuit breakers on the signing path#
Limits must be enforced at the signing service, not the caller:
const DAILY_LIMIT_USD: Record<string, number> = {
'USDC.polygon': 50_000,
'USDT.tron': 50_000,
'USDC.base': 100_000,
};
const HIGH_VALUE_USD = 10_000;Set per-chain and per-asset limits independently. A polygon key with a daily limit can't be abused to drain a base hot wallet even if both are compromised at the API layer. Monitor the running total in Redis with a 24-hour rolling window, not a calendar day — a calendar-day reset is exploitable at midnight.
Wallet migration (not "key rotation")#
Rotating an EOA signing key is not like rotating an API token. A new KMS key produces a different Ethereum address. Migration steps:
- Derive the Ethereum address for the new KMS key via
kmsEthAddress. - Update your gateway config so that new deposit address derivations point to the new hot wallet's sweep destination.
- Leave existing deposit addresses active — delayed on-chain deposits for old addresses still arrive and must be swept.
- Sweep the old hot wallet balance to the warm wallet using the old key.
- Monitor the old address for at least 30 days; sweep any residual inflows immediately.
- Schedule deletion of the old KMS key with a 30-day pending-deletion window (KMS minimum is 7 days; use 30 for safety).
- Confirm zero signing activity on the old key before the deletion executes.
Trigger migration after any security incident that touches the signing path, or on a quarterly schedule — whichever comes first.
Key takeaways#
- Raw key bytes in process memory = signing authority. HSM-backed KMS and MPC both prevent the key bytes from existing in any addressable memory.
MessageType: 'DIGEST'is not optional. Omitting it causes KMS to double-hash your input; the signature will be valid but sign a garbage message.- DER integers are variable-length. Read the length byte; never assume r and s are at a fixed byte offset in the returned signature blob.
- EIP-1559 typed transactions use
yParity(0/1), not legacyv = 27/28. Using27/28on typed transactions will produce a structurally invalid serialised transaction. - Restrict signing scope at the application layer. KMS and MPC protect the key; your signing service's allow-lists and daily limits cap the blast radius of a compromised caller.
- Hot wallet holds less than 24 hours of inflow. Anything above that threshold belongs in warm or cold storage where no automated process can reach it.
- Wallet migration is not a drop-in key rotation. Plan for 30 days of overlap, residual-sweep monitoring, and safe KMS key deletion.