Set up an AWS KMS key
This guide creates a secp256k1 signing key in AWS KMS, allows a deployer to sign with it and nothing else, adds it to a Hardhat project and checks that it signs.
With @hardhat-kms/aws, a connection lists the key's account and signs messages, typed data and transactions with it. kms history lists who signed with the key, when and from where, from the CloudTrail event history that AWS keeps for every account without any setup.
NOTE
Audience: users who sign with a key in AWS KMS.
1. Create a secp256k1 signing key
The commands on this page need AWS CLI v2; v1 reaches end of support on 2027-07-15.
Ethereum uses the secp256k1 curve, which AWS KMS calls ECC_SECG_P256K1:
aws kms create-key \
--key-spec ECC_SECG_P256K1 \
--key-usage SIGN_VERIFY \
--description "hardhat-kms deployer"
aws kms create-alias --alias-name alias/deployer --target-key-id <KeyId from the output above>The plugin refuses keys with another spec or usage.
Deleting the key loses its address for good, along with any funds it holds, once a waiting period of 7 to 30 days ends. Unlike Google Cloud and Azure, AWS KMS asks for the period when the deletion is scheduled, 30 days by default, not when the key is created, so the command above sets nothing for it (ScheduleKeyDeletion). Prevent and recover from losing a key covers cancelling a deletion, guarding against it and retiring a key.
2. Allow signing, and nothing else
The identity that runs Hardhat needs two permissions on this key. Which identity that is depends on where Hardhat runs and what its environment holds: Cloud credentials for KMS signing shows the laptop, CI and server cases. The conditions limit kms:Sign to what the plugin sends, a 32-byte digest signed with ECDSA over SHA-256:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "kms:GetPublicKey",
"Resource": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
},
{
"Effect": "Allow",
"Action": "kms:Sign",
"Resource": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"Condition": {
"StringEquals": {
"kms:SigningAlgorithm": "ECDSA_SHA_256",
"kms:MessageType": "DIGEST"
}
}
}
]
}Use the key ARN as the resource: an IAM policy cannot name a KMS key by its alias. create-key prints it as KeyMetadata.Arn, and aws kms describe-key --key-id alias/deployer --query KeyMetadata.Arn --output text prints it again. This IAM policy takes effect only if the key policy lets IAM policies grant access. The default key policy of a key made with create-key does; if you set your own key policy, grant these permissions there instead.
Save the policy as hardhat-kms-deployer.json, create it, and attach it to the role or user that runs Hardhat. This needs iam:CreatePolicy and iam:AttachRolePolicy or iam:AttachUserPolicy:
aws iam create-policy \
--policy-name hardhat-kms-deployer \
--policy-document file://hardhat-kms-deployer.json
# For a role, such as the role of a CI job:
aws iam attach-role-policy --role-name <role name> --policy-arn <Policy.Arn from the output above>
# Or for an IAM user:
aws iam attach-user-policy --user-name <user name> --policy-arn <Policy.Arn from the output above>Prefer a role, IAM Identity Center (aws sso login) or aws login (AWS CLI 2.32.0 or later) to an IAM user's access keys. AWS says "Where possible, we recommend relying on temporary credentials instead of creating long-term credentials such as access keys" (Security best practices in IAM). In CI, have the job assume a role through OIDC, for example with aws-actions/configure-aws-credentials and its role-to-assume input.
The 0.10.1 acceptance run on 2026-10-11 signed and deployed on Sepolia with a fresh role whose only policy granted kms:GetPublicKey and kms:Sign on the test key, with the conditions above. The test role was removed afterward.
The repository's AWS adapter and CloudTrail live-test suites also call DescribeKey and ListAliases during setup to find the key ARN and alias. Those suites need extra permissions; ordinary plugin signing does not.
Credentials come from the AWS SDK's default chain: access keys in the environment, then a ~/.aws profile or SSO session, then a web identity token, then the role of the container or machine. A key's profile option picks a named profile. Never set a profile and access keys in the environment together: today a profile, from profile or AWS_PROFILE, makes the SDK ignore the keys, and the SDK's own warning says a future version may prefer the keys instead. A CI job that exports keys therefore needs a config without a literal profile; on a laptop, set AWS_PROFILE instead, or see One config for a laptop and CI. An alias names a key in the credentials' own account and region, so pin the key's address. Credentials gives the full order and both rules.
3. Install the plugin and configure the key
npm install --save-dev hardhat-kms @hardhat-kms/awspnpm add --save-dev hardhat-kms @hardhat-kms/awsyarn add --dev hardhat-kms @hardhat-kms/awspnpm 11 and later need an allowBuilds entry, and Yarn 4 needs nodeLinker: node-modules: Install hardhat-kms gives the settings for each package manager.
@hardhat-kms/aws brings the AWS SDK (@aws-sdk/client-kms) with it, so there is nothing else to install. Add it to plugins; it loads hardhat-kms itself:
import hardhatKmsAws from "@hardhat-kms/aws";
import { configVariable, defineConfig } from "hardhat/config";
export default defineConfig({
plugins: [hardhatKmsAws],
kms: {
keys: {
deployer: {
provider: "aws",
keyId: "alias/deployer",
// Optional, recommended: run `npx hardhat kms accounts` and replace the next line with
// the `address` line it prints for this key. The plugin then refuses to sign if the key
// derives to another address.
// address: "0x…",
},
},
},
networks: {
sepolia: {
type: "http",
url: configVariable("SEPOLIA_RPC_URL"),
chainId: 11155111,
kmsAccounts: ["deployer"],
},
},
});keyId accepts a key id, a key ARN, an alias name or an alias ARN; the configuration reference lists every option. The region comes from the ARN if keyId is one, then the key's region, then kms.defaults.aws.region, then the SDK's own chain (AWS_REGION, then the profile's region).
To pin the key's address, run npx hardhat kms accounts. For a key without a pin it prints an address line; paste it into the key in place of the commented-out line (kms accounts).
configVariable("SEPOLIA_RPC_URL") reads the RPC URL when a network needs it: from an environment variable of that name (export SEPOLIA_RPC_URL=https://…), or from the Hardhat keystore (npx hardhat keystore set SEPOLIA_RPC_URL) when the config loads the keystore plugin. The config above does not: add import hardhatKeystore from "@nomicfoundation/hardhat-keystore"; and put hardhatKeystore in plugins, or load a Hardhat toolbox, which includes it. The script in step 4 uses it.
To use a key without a config entry, set AWS_KMS_KEY_ID and pass --kms aws; see Move KMS signing from Foundry to Hardhat. Such keys are added to the network selected with --network, or to default without one.
4. Check that the key signs
Save this script as scripts/check-kms.ts. It lists the accounts on sepolia, then signs the message hello with the last one, which is the KMS account:
import { network } from "hardhat";
const { provider } = await network.create("sepolia");
const accounts = await provider.request({ method: "eth_accounts" });
const address: unknown = Array.isArray(accounts) ? accounts.at(-1) : undefined;
if (typeof address !== "string") {
throw new Error("no accounts");
}
const signature = await provider.request({
method: "personal_sign",
params: ["0x68656c6c6f", address],
});
console.log(address, signature);Run it with npx hardhat run scripts/check-kms.ts. The KMS address comes last in eth_accounts, after any accounts of the node. Each run calls GetPublicKey once, before the first signature, then Sign once for the signature, or more if a request is retried (How many sign requests one call can send). An address pin does not save that call: the plugin checks the public key against the pin before it releases a signature, and it signs with the key ARN that GetPublicKey returns. A pin saves the call only where the plugin needs just the address, such as listing accounts with eth_accounts; the first signature and the kms tasks still read the public key (address).
One config for a laptop and CI
On a laptop, a key often signs through an SSO profile. In CI, the job exports access keys to the environment, and with today's AWS SDK a profile in the config makes it skip them (Never set a profile and environment keys together). Take profile from a configuration variable with an empty default, so each environment sets only what it needs. Take region the same way when the key lives in a region other than the profile's:
import hardhatKmsAws from "@hardhat-kms/aws";
import { configVariable, defineConfig } from "hardhat/config";
export default defineConfig({
plugins: [hardhatKmsAws],
kms: {
keys: {
deployer: {
provider: "aws",
keyId: configVariable("AWS_KMS_KEY_ID"),
profile: configVariable("AWS_KMS_PROFILE", { default: "" }),
region: configVariable("AWS_KMS_REGION", { default: "" }),
},
},
},
});On the laptop, sign in to the profile, then name it:
aws sso login --profile dev-sso
export AWS_KMS_PROFILE=dev-ssoIf access keys are also set in the laptop's environment, today's AWS SDK uses the profile and prints a Multiple credential sources detected warning once. The warning names AWS_PROFILE even when the profile comes from the config, holds no values, and says that a future version may prefer the environment keys. Unset the access keys on the laptop, so the key signs as the profile whichever source a later SDK prefers.
In CI, leave AWS_KMS_PROFILE unset. Its value is then empty, the key has no profile, and the SDK uses the job's environment keys. An empty AWS_KMS_REGION works the same way: the region then comes from kms.defaults.aws.region, then from the SDK's chain (AWS_REGION, then the profile's region). The empty default has a cost: a misspelled variable name also reads as empty, and the key then uses whatever credentials the environment holds. Pin the key's address so a different key behind the same alias fails (address). A variable without default fails at first use when it is unset. The configuration reference lists the rules for these variables.
How the plugin uses the key
- It calls
GetPublicKeyonce, checks the key spec, usage and algorithm, and takes the key ARN from the response. - It signs with that ARN, never with the alias you configured. Repointing the alias cannot change which key signs during a run, and an
addresspin catches the change on the next run. - It sends
SignwithMessageType: DIGESTandECDSA_SHA_256, and checks that the response names the same key and algorithm. - It parses every signature, normalizes it to low-S and verifies it against the public key before using it; see the security model.
- It adds
hardhat-kms/<version>to the end of the user agent of every request, so theuserAgentfield of a CloudTrail event ends inhardhat-kms/1.0.0(with your installed version). The client reports this tag and anyone can send the same string, so it marks the plugin's calls but proves nothing.
Audit logs
AWS CloudTrail records every successful Sign call on the key, whoever makes it, and some failed ones. kms history lists them for one key from CloudTrail event history:
npx hardhat kms history deployer --since 7dpnpm hardhat kms history deployer --since 7dyarn hardhat kms history deployer --since 7dWithout --since, the task reads the last 24 hours, and it lists at most 100 events, the newest; --limit takes up to 1000 (kms history).
There is nothing to turn on. Event history is on in every AWS account, holds every management event, keeps 90 days for each Region, and costs nothing to read. Events take minutes to appear: AWS says about 5 minutes on average, with no guarantee; in our tests on 2026-10-02 they took 2 to 3 minutes.
The read permission
The identity that runs kms history needs cloudtrail:LookupEvents. That action takes no resource, so the policy names *:
{
"Version": "2012-10-17",
"Statement": [{ "Effect": "Allow", "Action": "cloudtrail:LookupEvents", "Resource": "*" }]
}It lets the identity read every management event of the account in that Region, not only this key's. For a key named by an alias or a bare key id, the task also calls GetPublicKey once to learn the key ARN, with the kms:GetPublicKey permission that signing already needs. CloudTrail logs that call as a GetPublicKey event. For a key ARN, the task asks STS GetCallerIdentity for the account of the credentials, which needs no permission.
How it reads
CloudTrail files each Sign event under the key id the caller passed: the key ARN, the bare key id or an alias. A lookup by the key ARN alone would miss the calls made with the other two. So the task looks up the account's Sign events in the key's Region and keeps those whose resources list holds the key ARN. It pages through them newest first, 50 events a page, at most two requests a second. An event with no resources list is matched by the keyId the caller passed; one that names only an alias the task cannot tie to the key is counted, not listed, and the unattributed-events note gives the count. The task stops after 60 pages, which is 3,000 Sign events of all the account's keys, or after 90 seconds. If the range holds more, the task says that it stopped before reading the whole range; narrow it with --since and --until.
What CloudTrail logs, and what it does not
Each row comes from one CloudTrail event:
| Column or field | CloudTrail field |
|---|---|
time | eventTime, in whole seconds |
principal | userIdentity.arn. Empty for calls by AWS services, callers from other accounts and IAM Identity Center users: see invokedBy, callerAccountId and onBehalfOfUserId below |
sourceIp | sourceIPAddress |
userAgent | userAgent; the plugin's calls end in hardhat-kms/<version> |
requestId | requestID, set by AWS KMS: the $metadata.requestId the AWS SDK returns |
error | errorCode, and errorMessage with --show-ids |
keyResource | the key ARN in resources, shown with --show-ids |
extra | eventID, the identity's type, userName and invokedBy, crossAccount (whether the caller's account differs from the key's), messageType, signingAlgorithm, sharedEventID, the TLS version and readOnly, when logged |
extra, with --show-ids | the identity's accessKeyId and principalId; callerAccountId, the caller's account when there is no principal ARN and it is another account; onBehalfOfUserId for an Identity Center user; sourceIdentity and sessionIssuerArn (the role) for an assumed role; invokedByDelegateAccountId; the keyId the caller passed as requestKeyId; and vpcEndpointId |
CloudTrail never logs the digest, so the task cannot tell which signature an event made. An AWS KMS asymmetric key has no versions, so there is no key version either. Neither the message, the transaction nor the signature is logged.
Calls from other accounts and Regions
Event history is kept for each account and each Region. A call from another account that uses the key is recorded twice: in the caller's account and in the key's account, with the same sharedEventID. Reading with credentials of the key's account, in the key's Region, shows every successful Sign call on the key. Failed calls are logged only in some cases, and a call from another account that was refused for access is recorded only in the caller's account. The task reads the key's Region: the Region of the key ARN, or the one the key is configured with.
When the credentials belong to another account, the task can show only the calls recorded in that account, and it adds the other-account note. An empty history then gets the logging-not-confirmed note too, since the key may have signed for others. Run the task with credentials of the key's account to see every successful call. If STS cannot tell the account of the credentials, the task goes on and adds the caller-account-unknown note instead.
A multi-Region key (its id starts with mrk-) has replicas in other Regions that sign with the same key material. CloudTrail records a replica's calls in the replica's Region, under the replica's own ARN, so the task adds the multi-region-key note and never calls the history complete. Run kms history on each replica's key ARN to see them.
Errors
Each message starts with the provider, the operation and the key, for example aws, sign, key aws:alias/deployer: the provider call failed (AccessDeniedException). The table lists the part after the colon.
| Error | Cause and fix |
|---|---|
AWS KMS keys need the @hardhat-kms/aws plugin | Run npm install --save-dev @hardhat-kms/aws in the Hardhat project, and add hardhatKmsAws to plugins in the config. |
@hardhat-kms/aws … needs hardhat-kms …, but hardhat-kms … is installed | The two packages are released together and must be the same version. Run the install command the error prints. |
the key spec is …, not ECC_SECG_P256K1 (secp256k1) | The key is not a secp256k1 key. A key's spec cannot be changed, so create a new key as in step 1. |
the key derives to 0x…, but the configured address is 0x… | The alias points at another key, or the pin is wrong. Nothing was signed. Find out why before you change the pin; see When the pin fails. |
the provider call failed (AccessDeniedException) | The identity lacks kms:GetPublicKey or kms:Sign on this key, the Sign conditions do not match, or the key policy does not allow IAM access. |
the provider call failed (NotFoundException) | The key id or alias does not exist in this account and region. Check keyId and the region. |
the provider call failed (DisabledException) | The key is disabled. Enable it with aws kms enable-key --key-id <key id or ARN>. |
the provider call failed (KMSInvalidStateException) | The key's state does not allow the call, usually because it is pending deletion. Run aws kms cancel-key-deletion --key-id <key id or ARN>, then aws kms enable-key --key-id <key id or ARN>. Both take a key id or key ARN, not an alias. |
no AWS region is configured | Set region on the key or kms.defaults.aws.region, set AWS_REGION, give the profile a region, or use a key ARN. |
no answer within … ms | KMS did not answer in time. Check the network and region, or raise timeoutMs. |
cannot read the audit log: the credentials lack cloudtrail:LookupEvents | kms history needs cloudtrail:LookupEvents; see The read permission. |
cannot find the key ARN: the credentials lack kms:GetPublicKey | kms history reads the key ARN of an alias or a bare key id with GetPublicKey. Grant it, or set keyId to the key ARN. |
the audit log kept refusing requests as too frequent | CloudTrail allows two lookups a second per account and Region, shared with other tools. Wait a minute, or narrow the range. |
Provider errors show only the error's class name, never its message, since SDK messages can carry request details. Run with DEBUG=hardhat:kms:* to see each call; see Debug output. The table lists the most common errors; the errors reference lists every one, with its id, cause and fix, and the core plugin's errors too.