Skip to main content
This guide walks you through:
  • How to use AWS Key Management Service (KMS) to generate and manage a secure private key that acts as the signer for your treasury wallet.
  • Creating, signing and submitting transactions for your wallet, using the KMS.
To complete this setup, your AWS user or role will need the following permissions:
PermissionPurpose
kms:CreateKeyCreate the KMS asymmetric key for signing
iam:CreateRoleCreate the IAM role for Lambda execution
iam:PutRolePolicyAttach inline policies to the IAM role
iam:PutGroupPolicyConfigure group-level policies if needed
ec2:DescribeVpcEndpointsConfigure VPC endpoints for private API access
apigateway:UpdateRestApiPolicyUpdate API Gateway resource policies

Why Use AWS KMS as Signer for a Treasury Wallet?

Crossmint Treasury Wallets support a myriad of different signers. From email/phone number backed up keys, to passkeys, to bringing your own signer. AWS KMS is a fully managed service for creating, controlling, and managing cryptographic keys. Crossmint recommends using it as an external signer for treasury wallets, for bank grade security with high performance. It has the following properties:
  1. Confidential Key Generation and Custody: generate private keys within hardware security modules (HSMs). No one —including your own team— ever sees or has access to the raw key material.
  2. Granular Access Control: define explicit permissions on who can perform signing operations using AWS IAM roles and policies. Integrate seamlessly with your organization’s existing authentication and authorization infrastructure.
  3. Advanced Observability: enable Cloud Trail to log every operation, and use Cloud Watch for anomaly detection and alerting.
  4. High Performance: operate over 1000 TPS with tens of milliseconds of latency.

Integration Steps

0. Pre-requisites

Before you begin, ensure you have:
  1. An AWS account with appropriate permissions to create KMS keys, IAM roles, Lambda functions, and API Gateway resources
  2. AWS CLI installed on your machine (for testing)

1. Create a key in AWS KMS

Create an asymmetric key in AWS KMS that will be used as your wallet signer
This guide is written for EVM-compatible blockchains. The steps for Solana or Stellar are similar, but key settings change. Contact your Crossmint support representative for assistance.
1

Access AWS KMS

Log in to your AWS account and search for “KMS” in the AWS Console. Navigate to the Key Management Service.
2

Create a Customer Managed Key

Go to Customer managed keys and click Create key.
3

Configure Key Settings

Configure your key with the following settings:
SettingValue
Key typeAsymmetric
Key usageSign and verify
Key specECC_SECG_P256K1
The ECC_SECG_P256K1 specification is the secp256k1 elliptic curve, which is the same curve used by Ethereum and other EVM-compatible blockchains for cryptographic operations.
4

Name Your Key

Give your key a descriptive alias that identifies its purpose, for example: treasuryABC-admin-signer.
5

Set Permissions

Specify the key administrators and users who should have access to manage and use this key.
6

Copy the Public Key

After creating the key, navigate to the key’s detail page and go to the Public key tab. Click Download to download the public key in PEM format, or copy the public key content directly.
Save this public key securely. You’ll need it in the next step to derive the Ethereum address for your wallet signer.

2. Set up KMS Signing Policy

Next, create an IAM role that allows to call KMS for signing operations.
In this guide, we use an AWS Lambda function to handle signing requests. However, this architecture can be adapted to run on other compute types such as EC2 instances or ECS containers depending on your infrastructure requirements.
1

Create a New Role

Search for “IAM” in the AWS Console, navigate to Roles, and click Create role.
2

Configure Role Type

Keep AWS service selected and choose Lambda under “Service or use case”.
3

Skip Permission Policies

Skip the permission policies step for now (you’ll add an inline policy next).
4

Name the Role

Give your role a descriptive name, for example: adminSignerInvoker.
5

Add Inline Policy

From within the new role page, select Add Permissions and then Create inline policy.
6

Configure the Policy

Set the Policy editor to JSON and add the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": ["kms:Sign", "kms:GetPublicKey"],
            "Resource": "arn:aws:kms:REGION:ACCOUNT_ID:key/KEY_ID"
        }
    ]
}
Replace the placeholders with your actual values:
PlaceholderWhere to Find It
REGIONYour AWS region (e.g., us-east-1, eu-north-1)
ACCOUNT_IDClick your profile icon in the top-right corner of AWS Console
KEY_IDFound on your KMS key’s dashboard page
7

Save the Policy

Name the policy (e.g., kms.policy) and click “Create policy”.

3. Create a Crossmint Treasury Wallet with Key as a Signer

Follow the steps on the Treasury Wallets quickstart, passing the public key created earlier as the signer address. Since AWS KMS provides the public key in PEM format, you need to convert it to an Ethereum address. The code below handles this conversion by parsing the PEM-encoded public key and deriving the corresponding Ethereum address.
Replace the publicKeyPem placeholder with the public key you copied from AWS KMS in the previous step.
Node.js
import { CrossmintWallets, createCrossmint } from "@crossmint/wallets-sdk";
import { publicKeyToAddress } from "viem/accounts";
import { AsnParser } from "@peculiar/asn1-schema";
import { SubjectPublicKeyInfo } from "@peculiar/asn1-x509";

// Paste the public key from your AWS KMS key here
const publicKeyPem = `-----BEGIN PUBLIC KEY-----
<YOUR_KMS_PUBLIC_KEY_HERE>
-----END PUBLIC KEY-----
`;

function pemToDer(pem: string): Uint8Array {
    const b64 = pem
        .replace(/-----BEGIN PUBLIC KEY-----/g, "")
        .replace(/-----END PUBLIC KEY-----/g, "")
        .replace(/\s+/g, "");
    return Uint8Array.from(Buffer.from(b64, "base64"));
}

function kmsPemToEthAddress(pem: string): `0x${string}` {
    const der = pemToDer(pem);

    // Parse SPKI and extract the BIT STRING (the actual EC point bytes)
    const spki = AsnParser.parse(der, SubjectPublicKeyInfo);
    const uncompressed = new Uint8Array(spki.subjectPublicKey); // 65 bytes: 04 || X || Y

    if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) {
        throw new Error("Expected uncompressed secp256k1 public key (65 bytes starting with 0x04).");
    }

    const publicKeyHex = `0x${Buffer.from(uncompressed).toString("hex")}` as `0x${string}`;
    return publicKeyToAddress(publicKeyHex);
}

const crossmint = createCrossmint({
    apiKey: "<your-server-api-key>",
});

const crossmintWallets = CrossmintWallets.from(crossmint);

const wallet = await crossmintWallets.createWallet({
    chain: "base-sepolia",
    signer: {
        type: "external-wallet",
        address: kmsPemToEthAddress(publicKeyPem),
    },
    owner: "COMPANY",
    alias: "treasury",
});

console.log(wallet.address);
Install the following packages to parse the PEM public key:
npm install @peculiar/asn1-schema @peculiar/asn1-x509
The viem package should already be available as a peer dependency of @crossmint/wallets-sdk.

4. Create a Lambda Function to Handle Signature Requests

Now create a Lambda function that will handle signing requests using your KMS key.
1

Create the Function

Search for “Lambda” in the AWS Console and click Create function. Give your function a name, for example: adminSigner.
2

Configure Execution Role

Under Change default execution role, select Use an existing role. From the Existing role dropdown, select the role you created during IAM Role Setup (e.g., adminSignerInvoker).
3

Set Environment Variables

Create a new environment variable KMS_KEY_ID and set it to your KMS key ID. You can find the key ID at the top of the KMS key’s dashboard page.KMS Key ID
4

Add the Function Code

Enter the following code in the Lambda function editor:
import { KMSClient, SignCommand } from '@aws-sdk/client-kms'

const kms = new KMSClient({})

const KEY_ID = process.env.KMS_KEY_ID
if (!KEY_ID) throw new Error('Missing env var KMS_KEY_ID')

// secp256k1 curve parameters
const SECP256K1_P = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F')
const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141')
const SECP256K1_HALF_N = SECP256K1_N / 2n
const SECP256K1_Gx = BigInt('0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798')
const SECP256K1_Gy = BigInt('0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8')

export const handler = async (event) => {
  const body = typeof event.body === 'string' ? JSON.parse(event.body) : (event.body ?? event)

  const digestInput = body.userOpHash ?? body.useropHash ?? body.hash ?? body.digest
  if (!digestInput) {
    return resp(400, {
      error: 'Missing userOpHash (0x..32 bytes) or digest (0x..32 bytes or base64 32 bytes)'
    })
  }

  const signerAddress = body.signerAddress
  if (!signerAddress || !signerAddress.startsWith('0x') || signerAddress.length !== 42) {
    return resp(400, {
      error: 'Missing or invalid signerAddress (must be 0x-prefixed 20-byte hex address)'
    })
  }

  const digestBytes = parse32ByteDigest(digestInput)
  if (!digestBytes) return resp(400, { error: 'Digest must be 32 bytes (hex 0x.. or base64)' })

  // Apply EIP-191 personal sign prefix before signing
  // This computes: keccak256("\x19Ethereum Signed Message:\n32" + digestBytes)
  const ethSignedHash = hashMessage(digestBytes)

  const out = await kms.send(
    new SignCommand({
      KeyId: KEY_ID,
      Message: ethSignedHash,
      MessageType: 'DIGEST',
      SigningAlgorithm: 'ECDSA_SHA_256'
    })
  )

  if (!out.Signature) return resp(500, { error: 'KMS did not return a signature' })

  let { r, s } = derToRS(Buffer.from(out.Signature))

  // Enforce low-s (EIP-2)
  let sBig = BigInt(s)
  let sFlipped = false
  if (sBig > SECP256K1_HALF_N) {
    sBig = SECP256K1_N - sBig
    s = toPaddedHex(sBig, 32)
    sFlipped = true
  }

  // Determine the correct recovery parameter by trying both and comparing addresses
  const rBig = BigInt(r)
  const hashBig = BigInt('0x' + Buffer.from(ethSignedHash).toString('hex'))

  let correctV = null
  const recoveredAddresses = []
  for (const recoveryParam of [0, 1]) {
    try {
      const recoveredPubKey = ecrecover(hashBig, rBig, sBig, recoveryParam)
      if (!recoveredPubKey) {
        recoveredAddresses.push({ v: recoveryParam + 27, error: 'recovery returned null' })
        continue
      }
      
      const recoveredAddress = pubKeyToAddress(recoveredPubKey)
      recoveredAddresses.push({ v: recoveryParam + 27, address: recoveredAddress })
      
      if (recoveredAddress.toLowerCase() === signerAddress.toLowerCase()) {
        correctV = recoveryParam + 27
        break
      }
    } catch (err) {
      recoveredAddresses.push({ v: recoveryParam + 27, error: err.message })
    }
  }

  if (correctV === null) {
    return resp(500, { 
      error: 'Failed to determine correct signature recovery parameter. Verify signerAddress matches the KMS key.',
      debug: {
        expectedAddress: signerAddress,
        recoveredAddresses,
        r,
        s
      }
    })
  }

  const signature = hexConcat([r, s, toPaddedHex(BigInt(correctV), 1)])

  return {
    statusCode: 200,
    headers: { 'content-type': 'application/json' },
    body: { signature }
  }
}

function resp(statusCode, body) {
  return { statusCode, headers: { 'content-type': 'application/json' }, body }
}

// EIP-191 personal sign: keccak256("\x19Ethereum Signed Message:\n" + len + message)
function hashMessage(messageBytes) {
  const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${messageBytes.length}`, 'utf8')
  const prefixedMessage = Buffer.concat([prefix, messageBytes])
  return Buffer.from(keccak256(prefixedMessage))
}

function parse32ByteDigest(input) {
  if (typeof input !== 'string') return null
  if (input.startsWith('0x')) {
    try {
      const bytes = Buffer.from(input.slice(2), 'hex')
      return bytes.length === 32 ? bytes : null
    } catch {
      return null
    }
  }
  try {
    const bytes = Buffer.from(input, 'base64')
    return bytes.length === 32 ? bytes : null
  } catch {
    return null
  }
}

function derToRS(der) {
  let i = 0
  if (der[i++] !== 0x30) throw new Error('Invalid DER: expected SEQUENCE')
  const seq = readDerLen(der, i)
  i = seq.next
  if (der[i++] !== 0x02) throw new Error('Invalid DER: expected INTEGER (r)')
  const rLen = readDerLen(der, i)
  i = rLen.next
  const rBytes = der.slice(i, i + rLen.len)
  i += rLen.len
  if (der[i++] !== 0x02) throw new Error('Invalid DER: expected INTEGER (s)')
  const sLen = readDerLen(der, i)
  i = sLen.next
  const sBytes = der.slice(i, i + sLen.len)
  return { r: bytesToPaddedHex(stripLeadingZero(rBytes), 32), s: bytesToPaddedHex(stripLeadingZero(sBytes), 32) }
}

function readDerLen(buf, offset) {
  const first = buf[offset]
  if (first < 0x80) return { len: first, next: offset + 1 }
  const n = first & 0x7f
  let len = 0
  for (let j = 0; j < n; j++) len = (len << 8) | buf[offset + 1 + j]
  return { len, next: offset + 1 + n }
}

function stripLeadingZero(b) {
  return (b.length > 1 && b[0] === 0x00) ? b.slice(1) : b
}

function bytesToPaddedHex(b, size) {
  return '0x' + Buffer.from(b).toString('hex').padStart(size * 2, '0')
}

function toPaddedHex(n, size) {
  let hex = n.toString(16)
  if (hex.length > size * 2) hex = hex.slice(-size * 2)
  return '0x' + hex.padStart(size * 2, '0')
}

function hexConcat(parts) {
  return '0x' + parts.map((p) => (p.startsWith('0x') ? p.slice(2) : p)).join('')
}

// ============================================================================
// Pure JavaScript secp256k1 ecrecover implementation
// ============================================================================

// Modular arithmetic helpers
function mod(a, m) {
  const result = a % m
  return result >= 0n ? result : result + m
}

function modInverse(a, m) {
  a = mod(a, m) // Ensure a is positive before computing inverse
  if (a === 0n) throw new Error('No modular inverse for 0')
  let [old_r, r] = [a, m]
  let [old_s, s] = [1n, 0n]
  while (r !== 0n) {
    const q = old_r / r
    ;[old_r, r] = [r, old_r - q * r]
    ;[old_s, s] = [s, old_s - q * s]
  }
  return mod(old_s, m)
}

function modPow(base, exp, m) {
  let result = 1n
  base = mod(base, m)
  while (exp > 0n) {
    if (exp % 2n === 1n) result = mod(result * base, m)
    exp = exp / 2n
    base = mod(base * base, m)
  }
  return result
}

// Elliptic curve point operations (affine coordinates)
function pointAdd(p1, p2) {
  if (p1 === null) return p2
  if (p2 === null) return p1
  
  const [x1, y1] = p1
  const [x2, y2] = p2
  
  if (x1 === x2) {
    if (y1 !== y2) return null // point at infinity
    // Point doubling
    const s = mod(3n * x1 * x1 * modInverse(2n * y1, SECP256K1_P), SECP256K1_P)
    const x3 = mod(s * s - 2n * x1, SECP256K1_P)
    const y3 = mod(s * (x1 - x3) - y1, SECP256K1_P)
    return [x3, y3]
  }
  
  const s = mod((y2 - y1) * modInverse(x2 - x1, SECP256K1_P), SECP256K1_P)
  const x3 = mod(s * s - x1 - x2, SECP256K1_P)
  const y3 = mod(s * (x1 - x3) - y1, SECP256K1_P)
  return [x3, y3]
}

function pointMul(k, point) {
  let result = null
  let addend = point
  while (k > 0n) {
    if (k & 1n) result = pointAdd(result, addend)
    addend = pointAdd(addend, addend)
    k >>= 1n
  }
  return result
}

// Recover y-coordinate from x-coordinate on secp256k1
function recoverY(x, isOdd) {
  // y² = x³ + 7 (mod p)
  const ySquared = mod(modPow(x, 3n, SECP256K1_P) + 7n, SECP256K1_P)
  // y = ySquared^((p+1)/4) mod p (works because p ≡ 3 mod 4)
  const y = modPow(ySquared, (SECP256K1_P + 1n) / 4n, SECP256K1_P)
  // Verify
  if (mod(y * y, SECP256K1_P) !== ySquared) return null
  // Return the y with correct parity
  const yIsOdd = (y & 1n) === 1n
  return yIsOdd === isOdd ? y : mod(-y, SECP256K1_P)
}

// ecrecover: recover public key from signature
function ecrecover(msgHash, r, s, recoveryParam) {
  // Recover the point R from r and recoveryParam
  const isOdd = (recoveryParam & 1) === 1
  const x = r
  const y = recoverY(x, isOdd)
  if (y === null) return null
  
  const R = [x, y]
  const G = [SECP256K1_Gx, SECP256K1_Gy]
  
  // Public key P = r^(-1) * (s*R - e*G)
  const rInv = modInverse(r, SECP256K1_N)
  const u1 = mod(-msgHash * rInv, SECP256K1_N)
  const u2 = mod(s * rInv, SECP256K1_N)
  
  const point1 = pointMul(u1, G)
  const point2 = pointMul(u2, R)
  const pubKeyPoint = pointAdd(point1, point2)
  
  if (pubKeyPoint === null) return null
  
  // Return uncompressed public key (65 bytes: 0x04 || x || y)
  const xHex = pubKeyPoint[0].toString(16).padStart(64, '0')
  const yHex = pubKeyPoint[1].toString(16).padStart(64, '0')
  return Buffer.from('04' + xHex + yHex, 'hex')
}

function pubKeyToAddress(pubKey) {
  // Skip the 0x04 prefix and hash the 64 bytes
  const hash = keccak256(pubKey.slice(1))
  return '0x' + Buffer.from(hash.slice(12)).toString('hex')
}

// ============================================================================
// Pure JavaScript Keccak-256 implementation (no external dependencies)
// ============================================================================
const KECCAK_ROUNDS = 24
const ROTC = [1,3,6,10,15,21,28,36,45,55,2,14,27,41,56,8,25,43,62,18,39,61,20,44]
const PILN = [10,7,11,17,18,3,5,16,8,21,24,4,15,23,19,13,12,2,20,14,22,9,6,1]
const RNDC = [
  0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
  0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
  0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
  0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
  0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
  0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n
]

function rotl64(x, n) {
  n = BigInt(n)
  return ((x << n) | (x >> (64n - n))) & 0xffffffffffffffffn
}

function keccakF(state) {
  for (let round = 0; round < KECCAK_ROUNDS; round++) {
    // Theta
    const C = new Array(5)
    for (let x = 0; x < 5; x++) {
      C[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]
    }
    const D = new Array(5)
    for (let x = 0; x < 5; x++) {
      D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1)
    }
    for (let x = 0; x < 5; x++) {
      for (let y = 0; y < 5; y++) {
        state[x + y * 5] ^= D[x]
      }
    }

    // Rho and Pi
    let t = state[1]
    for (let i = 0; i < 24; i++) {
      const j = PILN[i]
      const temp = state[j]
      state[j] = rotl64(t, ROTC[i])
      t = temp
    }

    // Chi
    for (let y = 0; y < 5; y++) {
      const row = [state[y * 5], state[y * 5 + 1], state[y * 5 + 2], state[y * 5 + 3], state[y * 5 + 4]]
      for (let x = 0; x < 5; x++) {
        state[y * 5 + x] = row[x] ^ ((~row[(x + 1) % 5]) & row[(x + 2) % 5])
      }
    }

    // Iota
    state[0] ^= RNDC[round]
  }
}

function keccak256(input) {
  const inputBytes = Buffer.isBuffer(input) ? input : Buffer.from(input)
  const rate = 136 // (1600 - 256 * 2) / 8 for keccak-256
  const state = new Array(25).fill(0n)

  // Pad the input: append 0x01, then zeros, then 0x80
  const padLen = rate - (inputBytes.length % rate)
  const padded = Buffer.alloc(inputBytes.length + padLen)
  inputBytes.copy(padded)
  padded[inputBytes.length] = 0x01
  padded[padded.length - 1] |= 0x80

  // Absorb
  for (let offset = 0; offset < padded.length; offset += rate) {
    for (let i = 0; i < rate / 8; i++) {
      const lane = padded.readBigUInt64LE(offset + i * 8)
      state[i] ^= lane
    }
    keccakF(state)
  }

  // Squeeze (just 32 bytes for keccak-256)
  const output = Buffer.alloc(32)
  for (let i = 0; i < 4; i++) {
    output.writeBigUInt64LE(state[i], i * 8)
  }
  return output
}
5

Deploy the Function

Click Deploy to save and deploy your Lambda function
6

Optional: Expose Function as HTTP Endpoint

Then, make the function accessible to your main application.Below, you can see optional steps for enabling it as via an HTTP endpoint whitelisted to an IAM role.
Expose the function via an HTTP route, only callable from accounts with certain IAM role.
1

Create the API

Search for “API Gateway” in the AWS Console and click Create API. Select Build for REST API.
2

Configure API Settings

Name your API (e.g., KMS Sign API) and select a security policy that meets your requirements.
3

Create a Method

After creating the API, select Create method with the following settings:
SettingValue
Method typePOST
Lambda functionSelect the Lambda function you created
Authorization (In Method Request Settings)AWS IAM
4

Create a Stage

Go to Stages in the left panel and click Create Stage. Name the stage (e.g., prod).
5

Deploy the API

Go back to API > Resource and select Deploy API. Select the stage you created and click Deploy.
6

Add Resource Policy

Navigate to Resource policy in the left panel and add the following policy to allow invocations from your AWS account:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAnyoneInThisAccount",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/*/POST/",
            "Condition": {
                "StringEquals": {
                    "aws:PrincipalAccount": "ACCOUNT_ID"
                }
            }
        }
    ]
}
Replace the placeholders with your actual values:
PlaceholderWhere to Find It
REGIONYour AWS region (e.g., us-east-1, eu-north-1)
ACCOUNT_IDClick your profile icon in the top-right corner of AWS Console
API_IDFound in the API Gateway console URL or in the API’s ARN
After adding the policy, click Save and then re-deploy the API to the stage for the changes to take effect.
7

Save the Invoke URL

Copy the Invoke URL for your API. You’ll need this URL to test the endpoint. You can always find this URL under the Stages view.
8

Grant User Group Access to API Gateway

Add a policy to your user group to allow members to invoke the API Gateway endpoint.
  1. Search for IAM in the AWS Console and navigate to User groups in the left panel.
  2. Select the user group you want to grant access to (or create a new one).
  3. Go to the Permissions tab.
  4. Click Add permissions and select Create inline policy.
  5. Select the JSON tab in the policy editor.
  6. Paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:REGION:ACCOUNT_ID:API_ID/STAGE/POST/"
        }
    ]
}
Replace the placeholders with your actual values:
PlaceholderWhere to Find It
REGIONYour AWS region (e.g., us-east-1, eu-north-1)
ACCOUNT_IDClick your profile icon in the top-right corner of AWS Console
API_IDFound in the API Gateway console URL or in the API’s ARN
STAGEThe stage name you created (e.g., prod)
  1. Click Next, give the policy a name (e.g., api-gateway-invoke-policy), and click Create policy.
You can invoke the function using the AWS CLI to ensure everything is working correctly.
1

Open Security Credentials

Click on your username in the top right corner of the AWS Console, then select Security credentials from the dropdown menu.
2

Create Access Key

Scroll down to the Access keys section and click Create access key.
3

Select Use Case

Select the use case (e.g., “Command Line Interface (CLI)”) and click Create access key.
4

Copy Access Keys

Important: Copy both the Access key ID and Secret access key immediately. The secret access key will only be shown once and cannot be retrieved later.
5

Run AWS Configure

Run aws configure to set up your AWS credentials:
aws configure
6

Enter Access Key ID

When prompted, enter your AWS Access Key ID: Paste the access key ID you copied in the previous step.
7

Enter Secret Access Key

Enter your AWS Secret Access Key: Paste the secret access key you copied.
8

Enter Default Region

Enter your Default region name: Enter your AWS region (e.g., us-east-1, eu-north-1).
9

Set Output Format

For Default output format: Press Enter to use the default (JSON) or specify json, yaml, text, or table.Your credentials will be stored in ~/.aws/credentials and your configuration in ~/.aws/config.
10

Install awscurl

Install awscurl to make signed requests to your API:
pip install awscurl
11

Generate Signature

Run the following command to sign a message using your endpoint:
awscurl --service execute-api --region YOUR_REGION \
    -X POST "https://YOUR_API_ID.execute-api.YOUR_REGION.amazonaws.com/prod/" \
    -d '{"digest":"BASE64_ENCODED_MESSAGE_HASH", "signerAddress":"0xYOUR_KMS_SIGNER_ADDRESS"}'
Replace the placeholders with your actual values:
PlaceholderDescription
YOUR_REGIONYour AWS region (e.g., us-east-1, eu-north-1)
YOUR_API_IDFound in the API Gateway console URL or ARN (e.g., if ARN is arn:aws:execute-api:eu-north-1:257027486036:bho48fe8dl/*/POST/, then API_ID is bho48fe8dl)
BASE64_ENCODED_MESSAGE_HASHThe base64-encoded message hash returned from wallet APIs
0xYOUR_KMS_SIGNER_ADDRESSThe Ethereum address derived from your KMS public key (from step 3)

5. Initiate Regulated Transfer

Initiate a regulated transfer from your treasury wallet to a recipient. Before calling the API, ensure the recipient wallet is properly set up with registered personal data as described in the regulated transfers guide. The API returns a transaction object containing a message (hash) that needs to be signed by the adminSigner.
import { CrossmintWallets, createCrossmint } from "@crossmint/wallets-sdk";

const crossmint = createCrossmint({
    apiKey: "<your-server-api-key>",
});

const crossmintWallets = CrossmintWallets.from(crossmint);

const treasuryLocator = "<your-treasury-wallet-locator>";

const treasuryWallet = await crossmintWallets.getWallet(treasuryLocator, {
    chain: "base-sepolia",
    signer: { type: "external-wallet" },
});

try {
    const { transactionId } = await treasuryWallet.send(
        "<recipient-wallet-address>",
        "usdc",
        "100",
        { experimental_prepareOnly: true }
    );

    const transaction = await treasuryWallet.experimental_transaction(transactionId);

    console.log(transaction.approvals.pending[0].message);
} catch (error) {
    console.error("Transfer failed:", error.message);
    // Handle compliance errors - see error codes below
}
Extract the message hash to sign, as well as the transfer id.

6. Sign the Message

Send the message hash returned from the previous step to your AWS API Gateway endpoint. For testing, you can use the AWS CLI to submit the signature. This would have required you to expose the lambda as an HTTP API in step 4.
1

Install awscurl

Install awscurl to make signed requests to your API:
pip install awscurl
2

Generate Signature

Run the following command to sign a message using your endpoint:
awscurl --service execute-api --region YOUR_REGION \
    -X POST "https://YOUR_API_ID.execute-api.YOUR_REGION.amazonaws.com/prod/" \
    -d '{"digest":"BASE64_ENCODED_MESSAGE_HASH", "signerAddress":"0xYOUR_KMS_SIGNER_ADDRESS"}'
Replace the placeholders with your actual values:
PlaceholderDescription
YOUR_REGIONYour AWS region (e.g., us-east-1, eu-north-1)
YOUR_API_IDFound in the API Gateway console URL or in the API’s ARN
BASE64_ENCODED_MESSAGE_HASHThe base64-encoded message hash returned from wallet APIs
0xYOUR_KMS_SIGNER_ADDRESSThe Ethereum address derived from your KMS public key (from step 3)
The response will contain the signature with the correct recovery parameter:
{
    "signature": "0x16d4...1b"
}

7. Approve the Transaction

Finally, submit the signature to the Approve Transaction API to complete the transfer. The transaction is then broadcast to the blockchain.
const CROSSMINT_API_KEY = "<your-api-key>";
const treasuryLocator = "<your-treasury-wallet-locator>";
const transferId = "<transfer-id-from-step-5>";
const AWSKMSSignerAddress = "<wallet-address-from-kms-key>";

// Parse the KMS response from step 6
const kmsResponse = JSON.parse(`<response-from-step-6>`);
const signature = kmsResponse.signature;

const url = `https://staging.crossmint.com/api/2025-06-09/wallets/${treasuryLocator}/transactions/${transferId}/approvals`;

const payload = {
    approvals: [{
        signer: `external-wallet:${AWSKMSSignerAddress}`,
        signature: signature
    }]
};

const response = await fetch(url, {
    method: 'POST',
    headers: {
        'X-API-KEY': CROSSMINT_API_KEY,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify(payload)
});

if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
}

const result = await response.json();
console.log("Transaction approved:", result);

Security Best Practices

Follow these security best practices:
  1. Principle of Least Privilege: Only grant the minimum permissions necessary for each IAM role and user
  2. Audit Logging: Enable AWS CloudTrail to log all KMS and API Gateway operations
  3. Network Security: Consider using VPC endpoints for KMS to keep traffic within your AWS network
  4. Access Reviews: Regularly review who has access to your KMS keys and API Gateway endpoints

Next Steps