BchainPay logoBchainPay
EngineeringSecurityInfrastructureKey Management

Hot-Wallet Key Management for Crypto Payments: HSM, MPC and AWS KMS

Protect payment-gateway signing keys with AWS KMS secp256k1, MPC threshold schemes, and a hot/warm/cold wallet architecture — with working TypeScript examples.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for Hot-Wallet Key Management for Crypto Payments: HSM, MPC and AWS KMS

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:

  1. Private key in a .env file committed to git at any point in history
  2. 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
  3. Exception logging that serialises a config object containing the key
  4. Infrastructure compromise — an attacker with host access can read /proc/<pid>/mem or attach a debugger and dump heap
  5. 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.
  • r and s are variable-length ASN.1 INTEGERs. A leading 0x00 byte is inserted when the high bit is set (to keep the integer positive in DER). The parseDer function 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:

  1. Derive the Ethereum address for the new KMS key via kmsEthAddress.
  2. Update your gateway config so that new deposit address derivations point to the new hot wallet's sweep destination.
  3. Leave existing deposit addresses active — delayed on-chain deposits for old addresses still arrive and must be swept.
  4. Sweep the old hot wallet balance to the warm wallet using the old key.
  5. Monitor the old address for at least 30 days; sweep any residual inflows immediately.
  6. Schedule deletion of the old KMS key with a 30-day pending-deletion window (KMS minimum is 7 days; use 30 for safety).
  7. 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 legacy v = 27/28. Using 27/28 on 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.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading