Skip to content

Deploy a Hardhat contract to Sepolia with AWS KMS ​

In this tutorial you create a Hardhat project, create a signing key in AWS KMS, deploy a contract to Sepolia from that key and verify its source on block explorers. The private key never leaves AWS KMS: Hardhat asks KMS for a signature each time it sends a transaction.

You need:

  • Node.js 22.13.0 or later (see supported Node.js versions), and npm, pnpm or Yarn.
  • A POSIX shell, such as bash or zsh; on Windows, use WSL.
  • AWS CLI v2, signed in with an identity that can create KMS keys and aliases, and a region set: aws configure get region prints it, or set AWS_REGION. The plugin finds the same credentials and region as the CLI. AWS_DEFAULT_REGION is read by the CLI only, so set AWS_REGION if that is where your region comes from. AWS CLI v1 reaches end of support on 2027-07-15; aws --version prints aws-cli/2. for v2.
  • A Sepolia RPC URL. The examples use the public https://ethereum-sepolia-rpc.publicnode.com; a provider URL with an API key works too.
  • About 0.01 Sepolia ETH, from a faucet or another account.

NOTE

Audience: developers who have an AWS account and AWS CLI v2 signed in, and have not used AWS KMS with Hardhat. It takes about 15 minutes, plus the time it takes to get Sepolia ETH.

This tutorial was followed from an empty directory on 2026-10-01, at commit 7c4262e, with Hardhat 3.18.1 and @nomicfoundation/hardhat-verify 3.1.2. The commands took about 8 minutes, without the wait for Sepolia ETH. On 2026-10-07, at commit e10b7e5, with Hardhat 3.18.1, steps 1 to 4 were followed again from an empty directory, up to kms accounts --check-sign. That run installed packages packed from the repository and used LocalStack's KMS in place of AWS KMS.

1. Create a Hardhat project with the plugin ​

Create a project from Hardhat's viem template in an empty directory:

sh
mkdir kms-tutorial
cd kms-tutorial
npx --yes hardhat@latest --init --template node-test-runner-viem
sh
mkdir kms-tutorial
cd kms-tutorial
pnpm dlx hardhat@latest --init --template node-test-runner-viem
sh
mkdir kms-tutorial
cd kms-tutorial
yarn init -2
printf 'nodeLinker: node-modules\napprovedGitRepositories:\n  - "https://github.com/foundry-rs/forge-std.git"\n' >> .yarnrc.yml
yarn dlx hardhat@latest --init --template node-test-runner-viem

--yes lets npx download Hardhat without asking first; pnpm dlx and yarn dlx do not ask.

With Yarn, yarn init -2 pins Yarn 4 in package.json, so the template's install runs with Yarn 4 rather than Yarn 1. Hardhat does not run under Yarn 4's default Plug'n'Play linker, and the template installs forge-std from GitHub, which Yarn 4 refuses unless the repository is approved.

The template has a Counter contract, the Ignition module ignition/modules/Counter.ts that deploys it, and a sepolia network. It also installs @nomicfoundation/hardhat-verify, which verifies contracts on block explorers.

Install the core plugin, hardhat-kms, and the AWS provider, @hardhat-kms/aws, with the package manager you created the project with:

sh
npm install --save-dev hardhat-kms @hardhat-kms/aws
sh
pnpm add --save-dev hardhat-kms @hardhat-kms/aws
sh
yarn add --dev hardhat-kms @hardhat-kms/aws

If the install stops with ERR_PNPM_IGNORED_BUILDS from pnpm or YN0016 from Yarn, If the install stops gives the fix for each package manager.

Register the provider: in hardhat.config.ts, import it and add it to plugins. It loads hardhat-kms itself. The rest of the file stays as the template made it:

ts
import hardhatKmsAws from "@hardhat-kms/aws";
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
import { configVariable, defineConfig } from "hardhat/config";

export default defineConfig({
  plugins: [hardhatToolboxViemPlugin, hardhatKmsAws],
  // The rest of the template's config, unchanged.
});

Check that Hardhat finds the plugin:

sh
npx hardhat kms --help
sh
pnpm hardhat kms --help
sh
yarn hardhat kms --help

It lists the kms tasks, such as kms accounts and kms address. If it prints Error HHE404: Task "kms" not found instead, hardhatKmsAws is missing from plugins. You have created nothing in AWS yet, so an install problem costs nothing to fix.

2. Create a key in AWS KMS ​

A customer managed key in AWS KMS costs $1 a month, charged by the hour while the key exists, and each Sign or GetPublicKey call costs $0.15 per 10,000; these calls are not in the free tier. A key scheduled for deletion costs nothing. See AWS KMS pricing. Step 8 removes the key.

Create a secp256k1 signing key, which AWS KMS calls ECC_SECG_P256K1, and give it an alias:

sh
KEY_ID=$(aws kms create-key \
  --key-spec ECC_SECG_P256K1 \
  --key-usage SIGN_VERIFY \
  --description "hardhat-kms tutorial" \
  --query KeyMetadata.KeyId --output text)

aws kms create-alias --alias-name alias/hardhat-kms-tutorial --target-key-id "$KEY_ID"

The project refers to the key by its alias. Keep this shell open: step 8 uses KEY_ID, and so does the optional deployer policy.

3. Choose the identity that signs ​

Hardhat signs with the identity you signed in with. For this tutorial, that identity can be the one that created the key, so this step runs no command. Step 4 checks that it may sign.

A production deployer should have only kms:GetPublicKey, to derive the address, and kms:Sign, limited to what the plugin sends. The tutorial does not need that policy: Optional: give a deployer only the sign policy, just before step 8, writes it, gives it to a new role and signs as that role.

4. Add the key to the project ​

Replace hardhat.config.ts with the file below. Compared with the template, it keeps hardhatKmsAws in plugins from step 1, adds a kms section with the key, gives the sepolia network kmsAccounts instead of accounts, so no private key is in the project, sets the network's chainId and turns Etherscan verification off:

ts
import hardhatKmsAws from "@hardhat-kms/aws";
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
import { configVariable, defineConfig } from "hardhat/config";

export default defineConfig({
  plugins: [hardhatToolboxViemPlugin, hardhatKmsAws],
  solidity: {
    profiles: {
      default: {
        version: "0.8.34",
      },
      production: {
        version: "0.8.34",
        settings: {
          optimizer: {
            enabled: true,
            runs: 200,
          },
        },
      },
    },
  },
  verify: {
    etherscan: {
      enabled: false,
    },
  },
  kms: {
    keys: {
      deployer: {
        provider: "aws",
        keyId: "alias/hardhat-kms-tutorial",
      },
    },
  },
  networks: {
    hardhatMainnet: {
      type: "edr-simulated",
      chainType: "l1",
    },
    hardhatOp: {
      type: "edr-simulated",
      chainType: "op",
    },
    sepolia: {
      type: "http",
      chainType: "l1",
      chainId: 11155111,
      url: configVariable("SEPOLIA_RPC_URL"),
      kmsAccounts: ["deployer"],
    },
  },
});

Etherscan needs an API key, and without one its verification fails. To verify on Etherscan too, get a key from Etherscan, store it with npx hardhat keystore set ETHERSCAN_API_KEY or export ETHERSCAN_API_KEY=…, and replace enabled: false with apiKey: configVariable("ETHERSCAN_API_KEY").

chainId: 11155111 is Sepolia's chain id. Before the plugin signs a transaction, it asks the node for its chain; if SEPOLIA_RPC_URL points at another chain, it stops with core.chain.mismatch and signs nothing.

Set the RPC URL, then ask KMS for the key's address:

sh
export SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
DEPLOYER_ADDRESS=$(npx hardhat kms address deployer)
echo "$DEPLOYER_ADDRESS"
sh
export SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
DEPLOYER_ADDRESS=$(pnpm hardhat kms address deployer)
echo "$DEPLOYER_ADDRESS"
sh
export SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
DEPLOYER_ADDRESS=$(yarn hardhat kms address deployer)
echo "$DEPLOYER_ADDRESS"

It prints the key's address, 0x and 40 hex digits. Steps 5 and 6 use DEPLOYER_ADDRESS; in a new shell, run the same command again first. Pin the address: in hardhat.config.ts, add an address line to the deployer key, with the address in place of <deployer address>. The rest of the file stays the same:

ts
  kms: {
    keys: {
      deployer: {
        provider: "aws",
        keyId: "alias/hardhat-kms-tutorial",
        address: "<deployer address>",
      },
    },
  },

With the pin, the plugin refuses to sign if the alias ever points at another key. Rotate a key and pin its address explains why.

Before you send funds to the address, check that your credentials may sign with the key:

sh
npx hardhat kms accounts --check-sign
sh
pnpm hardhat kms accounts --check-sign
sh
yarn hardhat kms accounts --check-sign

It prints a table with one row for the key, with your address in place of <deployer address>:

text
NAME      PROVIDER  SOURCE    ADDRESS                                     PIN      SIGN  KEY ID
deployer  aws       kms.keys  <deployer address>                          matches  ok    aws:alias/hardhat-kms-tutorial

matches under PIN and ok under SIGN prove that your credentials may sign with the key and that its signatures recover to the pinned address. The check signs a random message, not a transaction, so it needs no funds. When the credentials may read the key but not sign with it, SIGN shows FAILED and an error: line under the row gives the reason:

text
NAME      PROVIDER  SOURCE    ADDRESS                                     PIN      SIGN    KEY ID
deployer  aws       kms.keys  <deployer address>                          matches  FAILED  aws:alias/hardhat-kms-tutorial
  error: the sign check failed: aws, sign, key aws:alias/hardhat-kms-tutorial: the provider call failed (AccessDeniedException)

Fix the cause before step 5. An AccessDeniedException here means your credentials are not allowed kms:Sign on the key; check the permissions of the identity you signed in with.

5. Fund the address ​

Send about 0.01 Sepolia ETH to the deployer address, from a Sepolia faucet or from another account. Deploying the Counter contract and calling it costs much less than that on Sepolia.

The address belongs to the KMS key alone. If the key is deleted, any funds and contract roles at the address are lost for good, so step 8 sends the funds back before it removes the key.

To see the balance, open the address on Sepolia Etherscan or Sepolia Blockscout.

To try the deployment on a local fork of Sepolia before you fund the address, add the sepoliaFork network and kms.simulatedBalance from Rehearse on a simulated network, then run step 6's command with --network sepoliaFork and without --verify. The rehearsal signs with the real key, so it also checks the key and its permissions. Ignition does not ask you to confirm, and it starts with "You are running Hardhat Ignition against an in-process instance of Hardhat Network": that is the fork, and the results are lost when the command ends. It ends with a Deployed Addresses list, as in step 6.

6. Deploy and verify ​

Deploy the module from the deployer address, and verify the contract, in one command:

sh
npx hardhat ignition deploy ignition/modules/Counter.ts --network sepolia --verify --default-sender "$DEPLOYER_ADDRESS"
sh
pnpm hardhat ignition deploy ignition/modules/Counter.ts --network sepolia --verify --default-sender "$DEPLOYER_ADDRESS"
sh
yarn hardhat ignition deploy ignition/modules/Counter.ts --network sepolia --verify --default-sender "$DEPLOYER_ADDRESS"

Ignition asks you to confirm the network; answer y. Each transaction usually costs one KMS Sign call; retries can add more. Naming the sender by its address keeps the deployer the same when you add accounts or keys to the network later; Choose the sender by address explains why.

Ignition deploys Counter, calls incBy(5), then verifies the contract on Blockscout and Sourcify. The end of the output looks like this:

text
[ CounterModule ] successfully deployed 🚀

Deployed Addresses

CounterModule#Counter - 0xc93b1fa3aB9Db68E28897528246b7ec4C5492865

Verifying deployed contracts

Verifying contract "contracts/Counter.sol:Counter" for network sepolia...

=== Blockscout ===

The contract at 0xc93b1fa3aB9Db68E28897528246b7ec4C5492865 has already been verified on Blockscout.

If you need to verify a partially verified contract, please use the --force flag.

Explorer: https://eth-sepolia.blockscout.com/address/0xc93b1fa3aB9Db68E28897528246b7ec4C5492865#code

=== Sourcify ===

The contract at 0xc93b1fa3aB9Db68E28897528246b7ec4C5492865 has already been verified on Sourcify.

If you need to verify a partially verified contract, please use the --force flag.

Explorer: https://sourcify.dev/server/repo-ui/11155111/0xc93b1fa3aB9Db68E28897528246b7ec4C5492865

Both explorers may answer "already verified": the template's Counter is a common contract, and they have seen its code before. Blockscout may instead submit the source and end with ✅ Contract verified successfully on Blockscout!; the contract is then verified too. Open the Explorer: link that Blockscout printed. If the page shows the contract as verified, you are done. If it shows a "verified twin" or a "similar match" instead, your contract is not verified yet: Verify the source on block explorers has the command to run.

7. Open the contract on the explorer ​

Open the Explorer: links from the output, with your contract's address:

  • Blockscout: https://eth-sepolia.blockscout.com/address/<contract address>#code shows the source, marked as verified, and the two transactions from your deployer address: the deployment and the incBy call.
  • Sourcify: https://sourcify.dev/server/repo-ui/11155111/<contract address> shows the files and "Exact Match" or "Match". "Match", formerly "partial match", means the bytecode matches except the metadata hash at its end, so the source can differ in comments, variable names or file paths; the contract is verified either way (Exact Match vs Match).
  • Etherscan: https://sepolia.etherscan.io/address/<contract address>#code. This tutorial does not verify on Etherscan, so the contract has no verified source of its own there. Etherscan may show the source of another contract with the same bytecode, marked "Similar Match".

The From field of each transaction is your deployer address. The signature came from AWS KMS; Hardhat never held a private key.

Optional: give a deployer only the sign policy ​

Skip to step 8 if you do not need this section.

This section sets up a production deployer, which the tutorial does not need: an identity that may read the key's public key and sign with it, and do nothing else. It creates that identity as an IAM role and signs with the key as the role. Run it before step 8, while the key exists, in the shell from step 2, which has KEY_ID. In a new shell, set KEY_ID again first with the describe-key command at the start of step 8's key removal. Step 8 removes what this section adds, the role included.

Write the policy with your key's ARN to a file in the project. The ARN holds your AWS account ID, so do not commit the file; step 8 deletes it:

sh
KEY_ARN=$(aws kms describe-key --key-id "$KEY_ID" --query KeyMetadata.Arn --output text)
POLICY=kms-sign-policy.json

cat > "$POLICY" <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "kms:GetPublicKey",
      "Resource": "$KEY_ARN"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Sign",
      "Resource": "$KEY_ARN",
      "Condition": {
        "StringEquals": {
          "kms:SigningAlgorithm": "ECDSA_SHA_256",
          "kms:MessageType": "DIGEST"
        }
      }
    }
  ]
}
EOF

If you have no deployer role, create one named hardhat-deployer. Its trust policy lets only the identity that runs these commands assume the role. It names your account and adds a condition on aws:userid, your identity's unique ID: an IAM user's ID, or for a role session, including an IAM Identity Center sign-in, the role's ID and your session name. That is the form AWS asks for with IAM Identity Center (IAM Identity Center principals, Principal key values). Because it names the account, your own permissions must also allow sts:AssumeRole on the role, as an administrator's do. Like the sign policy, the trust policy holds your account ID, so step 8 deletes its file too:

sh
read -r ACCOUNT_ID CALLER_USERID <<< "$(aws sts get-caller-identity --query '[Account,UserId]' --output text)"
TRUST=kms-deployer-trust.json

cat > "$TRUST" <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::$ACCOUNT_ID:root" },
      "Action": "sts:AssumeRole",
      "Condition": { "StringEquals": { "aws:userid": "$CALLER_USERID" } }
    }
  ]
}
EOF

aws iam create-role --role-name hardhat-deployer \
  --assume-role-policy-document "file://$TRUST" \
  --max-session-duration 3600 --output none

If you sign in as the account's root user, use an IAM identity instead: only an IAM user or role can call AssumeRole (Compare AWS STS credentials).

A new role has no permissions. Attach the policy to give it the two the plugin needs. For the role named hardhat-deployer, whether you created it above or had it already:

sh
aws iam put-role-policy --role-name hardhat-deployer --policy-name hardhat-kms-tutorial \
  --policy-document "file://$POLICY"

For an IAM user, use aws iam put-user-policy --user-name <user name> instead. If you sign in through IAM Identity Center, ask your administrator to add the policy to your permission set. Prefer a role, IAM Identity Center or aws login to an IAM user's access keys: AWS recommends "relying on temporary credentials instead of creating long-term credentials such as access keys" (Security best practices in IAM).

To check the policy without changing anything, AWS IAM Access Analyzer and the policy simulator accept the file:

sh
aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY \
  --policy-document "file://$POLICY" --output json

aws iam simulate-custom-policy --policy-input-list "$(cat "$POLICY")" \
  --action-names kms:Sign --resource-arns "$KEY_ARN" \
  --context-entries ContextKeyName=kms:SigningAlgorithm,ContextKeyValues=ECDSA_SHA_256,ContextKeyType=string \
                    ContextKeyName=kms:MessageType,ContextKeyValues=DIGEST,ContextKeyType=string \
  --query 'EvaluationResults[].EvalDecision' --output text

The first prints "findings": [] and the second allowed. The same simulation with another algorithm, such as ECDSA_SHA_384, or another action, such as kms:Decrypt or kms:ScheduleKeyDeletion, prints implicitDeny. Set up an AWS KMS key explains the conditions and the key policy.

The simulator only evaluates the policy. To sign as the role, assume it. sts assume-role returns credentials that expire after one hour; the commands keep them in shell variables and print nothing. A role you just created can take a few seconds before it can be assumed: if the command fails with AccessDenied, wait 10 seconds and run it again:

sh
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

read -r ROLE_KEY_ID ROLE_SECRET ROLE_TOKEN <<< "$(aws sts assume-role \
  --role-arn "arn:aws:iam::$ACCOUNT_ID:role/hardhat-deployer" \
  --role-session-name hardhat-kms-tutorial \
  --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)"

The function below runs one command with the role's credentials, so your own commands keep running as you. It removes AWS_PROFILE for that command: with AWS_PROFILE set, the AWS SDK that the plugin uses ignores access keys in the environment and signs as the profile (Credentials). Since the profile is gone, the function also passes your region in AWS_REGION. The function runs in a subshell and exports the credentials to that one command; they stay in unexported variables of this shell until step 8 unsets them. It stops with is empty if a role variable is missing:

sh
as_deployer() (
  region="${AWS_REGION:-$(aws configure get region)}"
  unset AWS_PROFILE
  export AWS_ACCESS_KEY_ID="${ROLE_KEY_ID:?is empty: run the aws sts assume-role command above}"
  export AWS_SECRET_ACCESS_KEY="${ROLE_SECRET:?is empty: run the aws sts assume-role command above}"
  export AWS_SESSION_TOKEN="${ROLE_TOKEN:?is empty: run the aws sts assume-role command above}"
  export AWS_REGION="$region"
  "$@"
)

as_deployer aws sts get-caller-identity --query Arn --output text

It prints arn:aws:sts::<account ID>:assumed-role/hardhat-deployer/hardhat-kms-tutorial: the role, not you. If it prints another identity, or stops with is empty, assume-role failed: run it again before you go on. Now check that the role may sign with the key:

sh
as_deployer npx hardhat kms accounts --check-sign
sh
as_deployer pnpm hardhat kms accounts --check-sign
sh
as_deployer yarn hardhat kms accounts --check-sign

It prints the same row as in step 4, with matches under PIN and ok under SIGN, this time signed by a role that holds only the sign policy. To deploy as the role, put as_deployer before step 6's command. When the credentials expire, run the assume-role command again.

8. Clean up ​

When you are done, send the remaining Sepolia ETH back, then disable the key and schedule its deletion. In a new shell, set SEPOLIA_RPC_URL again first, as in step 4.

Send the balance back with the script in Return the funds from a KMS address: save it as scripts/return-funds.ts, then run it with RETURN_TO set to an address with no code, such as your own wallet's:

sh
RETURN_TO=<return address> npx hardhat run scripts/return-funds.ts
sh
RETURN_TO=<return address> pnpm hardhat run scripts/return-funds.ts
sh
RETURN_TO=<return address> yarn hardhat run scripts/return-funds.ts

It ends with sent in <transaction hash>. If it stops with a one-line message instead, What the script refuses explains each one, and nothing was sent unless the line names a transaction.

Before you remove the key, open the deployer address on Sepolia Etherscan or Sepolia Blockscout and check that its balance is close to zero. Once the key is gone, nothing can move what is left.

Then remove the key. If you closed the shell since step 2, set KEY_ID again first; the alias still finds it:

sh
KEY_ID=$(aws kms describe-key --key-id alias/hardhat-kms-tutorial --query KeyMetadata.KeyId --output text)

Disable the key and schedule its deletion with the shortest waiting period AWS allows:

sh
aws kms disable-key --key-id "$KEY_ID"
aws kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7

Keep the alias. AWS deletes it with the key when the 7 days end, and until then it finds the key if you change your mind. To undo the deletion, cancel it, then enable the key; Prevent and recover from losing a key covers it:

sh
aws kms cancel-key-deletion --key-id "$KEY_ID"
aws kms enable-key --key-id "$KEY_ID"

After the 7 days, the key is gone for good, and nothing can sign for the address again.

While the alias exists, a second run of this tutorial stops at step 2's create-alias, because the name is taken. To run the tutorial again within the 7 days, give the alias another name, such as alias/hardhat-kms-tutorial-2, in step 2's create-alias and in step 4's keyId. Deleting the old alias with aws kms delete-alias --alias-name alias/hardhat-kms-tutorial frees the name without touching the key, but then only the key ID finds that key, so note it first if you may cancel the deletion.

If you followed Optional: give a deployer only the sign policy, delete its policy file, from the project directory:

sh
rm kms-sign-policy.json

If you attached the policy to a role there, remove it from the role:

sh
aws iam delete-role-policy --role-name hardhat-deployer --policy-name hardhat-kms-tutorial

For an IAM user, use aws iam delete-user-policy --user-name <user name> --policy-name hardhat-kms-tutorial instead. If your administrator added the policy to a permission set, ask them to remove it.

If you created the hardhat-deployer role there, delete it now that its policy is gone; IAM refuses to delete a role that still has a policy. Then delete the trust policy file and the role's credentials:

sh
aws iam delete-role --role-name hardhat-deployer
rm kms-deployer-trust.json
unset ROLE_KEY_ID ROLE_SECRET ROLE_TOKEN

Credentials the role handed out stay valid until they expire, within the hour, but with the policy removed they can no longer use the key.

To keep the key instead, run only the disable-key command: a disabled key cannot sign, aws kms enable-key brings it back, and it still costs $1 a month.

Next steps ​

MIT licensed. A community plugin, not affiliated with or endorsed by Nomic Foundation, Amazon Web Services, Google or Microsoft.