Nonceworks

Moving a Relayer key to AWS KMS

Written 5 October 2026

For a relayer or bot that signs with a private key from an environment variable or a keystore file, and should sign through AWS KMS instead. Below: the two calls the Relayer 1.8.0 aws_kms signer makes, an IAM policy that allows only those, the order of the move, and how to revoke. Source: src/services/aws_kms/mod.rs in the 1.8.0 tree. Written for people running the relayer themselves and for teams handing a signing grant to someone who runs it for them.

Calls the signer makes

  • kms:GetPublicKey once at startup, to derive the relayer's address from the public key.
  • kms:Sign with SigningAlgorithm = ECDSA_SHA_256 and MessageType = DIGEST for every signature: each transaction, each retry while KMS refuses, the no-op that fills a nonce after a long outage, and the two sign endpoints.

We ran this end to end on 2026-10-07, first against LocalStack's KMS emulator (pointed there with AWS_ENDPOINT_URL_KMS; there is no config field for it), then against a real AWS KMS key with an IAM user holding exactly the policy above. In both, the derived address matched the key, a transaction signed through KMS was mined from it on a local anvil chain, and both sign endpoints returned signatures that recover to it. No public network was involved. The emulator run also showed the relayer calling GetPublicKey once per process and otherwise only Sign, every Sign DIGEST with ECDSA_SHA_256 over 32 bytes.

Nothing else. The relayer never receives key material; for EVM it only gets signatures over 32-byte digests.

The key

Create an asymmetric key, usage Sign and verify, key spec ECC_SECG_P256K1 (secp256k1). Note the key ARN and the region. For Solana or Stellar relayers the key spec is ECC_NIST_EDWARDS25519, and the relayer calls kms:Sign with ED25519_SHA_512 on the raw message rather than a digest.

The policy

Attach this to the IAM identity the relayer runs as (a dedicated IAM user with an access key, or a role it assumes). Replace the ARN:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["kms:GetPublicKey", "kms:Sign"],
    "Resource": "arn:aws:kms:us-east-1:123456789012:key/11111111-2222-3333-4444-555555555555"
  }]
}

Optionally add a key-policy statement that denies kms:Sign to every principal except that identity: a Deny with a StringNotEquals condition on aws:PrincipalArn. Use a Deny, not an Allow. An Allow in the key policy does not keep other principals out while the default account statement stays, and it keeps working after you detach the IAM policy, so detaching would no longer revoke.

The relayer side

{"id": "kms-signer", "type": "aws_kms", "config": {"region": "us-east-1", "key_id": "arn:aws:kms:us-east-1:123456789012:key/1111..."}}

key_id accepts a key id, key ARN, alias or alias ARN. Credentials come from the environment the usual AWS way: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN if they are temporary. They are not written into config.json.

After the relayer starts, GET /api/v1/relayers shows the derived address. Fund that address with gas. The key stays in KMS, and the relayer process never has it.

Moving off a local key

A key generated in KMS has never existed outside it, which is the point of the move, and it also means a new address. The order that keeps nothing stranded:

  1. Create the KMS key and attach the policy above. Add a second relayer with the aws_kms signer and a new relayer id next to the one that signs with the old key, and read the new address from GET /api/v1/relayers. With the default in-memory storage, adding both to config.json and restarting is enough. With Redis storage, 1.8.0 reads config.json only into empty storage or on a start with RESET_STORAGE_ON_START=true, and a reset start drops the stored transactions and nonce counters, so either reset with nothing in flight or add the signer and the relayer through POST /api/v1/signers and POST /api/v1/relayers. We read those two endpoints in the 1.8.0 source but have not used them in our runs.
  2. Give the new address what the old one has wherever a contract allows more than one holder: access control roles, receiver whitelists, trusted forwarders. Single slots, such as a stored keeper or operator address or a contract that compares msg.sender with one address, wait for step 4.
  3. Fund the new address with a small amount, send one transaction through the new relayer id, and check it on the chain you use.
  4. Point your callers at the new relayer id and swap the single slots in the same change; on a Safe that is one batch, and behind a timelock the delay is part of the move. Leave the old relayer running until its own transactions are confirmed or cancelled, so no nonce of the old address is left pending.
  5. The old key is the same address on every EVM chain. On each chain it was used on, move what it still holds, tokens included, and remove its roles and any ownership it has, such as a Safe owner or an Ownable owner. Then remove the old relayer and delete the old key (the keystore file or the variable that held it) from the host, the secret store and any backups. That deletion cannot be undone, so it comes last.

We have run this signer against one real AWS KMS key on a local anvil chain, with one test each of disabling the key and deleting the inline policy; not against a public network, and not with Google Cloud KMS or Turnkey, so a move to those starts with a testnet run of your own key.

Revoking

Any one of these stops the next signature once it propagates. Measured once on the real key, checking every 5 seconds: disabling it stopped the relayer's signatures within 33 seconds (the last good one came at 28) and re-enabling restored them within 21 seconds; deleting the inline IAM policy took 3 minutes 38 seconds, and putting it back took 3 minutes 40 seconds before signatures worked again. One run each, so treat these as one data point, not a guarantee. Disable the key first when you need it fast, and when you rotate a policy, add the new grant before you remove the old one.

  • detach or delete the policy;
  • disable the KMS key (reversible), or schedule it for deletion (7 to 30 days, reversible until then).

Deleting or deactivating the access key also stops the relayer, but not session tokens someone else minted from that key with GetSessionToken, which needs no permission and lasts up to 36 hours. Stopping a role assumption does not end sessions already issued, and the AWS SDK in the relayer keeps using its session until it expires. After a suspected compromise, revoke at the policy or key level.

What the relayer does meanwhile, from the same emulator run with the key disabled: it keeps accepting requests, each Sign fails with DisabledException and is retried, and the log shows AWS KMS sign failed. If signing works again within two minutes, queued transactions go out normally. After two minutes it marks them failed; their reserved nonce is filled with a no-op when a later transaction comes up for resubmission, which held the next transaction back by about three minutes on anvil.

If someone else runs the relayer for you

This is the whole grant. They get an identity with the policy above and the key ARN. Every Sign call on that key appears in CloudTrail with the caller, time, source IP and algorithm, though not the digest or the signature, so you match calls to the transactions you expect by count and timing. You can revoke in your own console at any time. If they ask for anything beyond kms:GetPublicKey and kms:Sign on that one resource, ask why.