$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

Secure Foundry Deployment Scripts: Keys, Proxies and Checks

By the Guardian team Updated 6 min read

Secure Foundry Deployment Scripts: Keys, Proxies and Checks

Quick answer. Keep the key out of .env: import it into an encrypted keystore with cast wallet import and sign with --account, or use --ledger or --trezor. Simulate without --broadcast first, and end with ownership handed to a multisig.

Watch: Secure Foundry Deployment Scripts: Keys, Proxies and Checks 1:40
Video transcript

Audited contracts can still fail at deployment. A leaked key, an uninitialized proxy or a wrong address is enough.

A key in a dot env file is readable by anything that runs as you. In 2025, the Shai-Hulud worm harvested environment variables for secrets, Wiz reported.

Follow Foundry's own advice. Import the key into an encrypted keystore with cast wallet import, and sign with the account flag. On mainnet, use a hardware wallet.

Run forge script without broadcast first, and read every address in the dry run. Then replay it on an Anvil fork from the real deployer, and broadcast with the slow flag.

A script with several transactions is not atomic. A proxy initialized afterwards is open to anyone in between, so pass the init data to its constructor.

Check every address on every chain, and every argument. A Nomad upgrade set zero as a trusted root, and about 190 million dollars was drained.

Afterwards, check what reached the chain, not what the script meant. Verify bytecode, read each proxy's implementation slot, and confirm every role sits with the multisig.

Guardian's offchain team, led by HackTricks creator Carlos Polop, runs OpSec audits and infrastructure pentests. Managed Security reviews multisig transactions before they are signed.

Guardian has verified USDT zero deployments since February 2025. Read the full guide, or talk to Guardian at guardian.security.

A Foundry deployment script is where audited code meets a real key, real arguments and a live chain.

Nothing here needs a bug in the contracts. A leaked deployer key, a proxy left uninitialized between two transactions or a wrong address is enough.

This guide covers the key behind forge script --broadcast, the dry run before it and the checks after it. For the wider launch list, see our pre-mainnet security checklist.

Why .env Keys Leak

A common pattern, shown in Foundry's own scripting docs, reads the key with vm.envUint("PRIVATE_KEY"). Foundry's best practices are firmer: "Store production keys in encrypted keystores or hardware wallets."

Its sample .env.example says it in one line: "Use cast wallet import instead of PRIVATE_KEY".

A plaintext key in a file is readable by anything that runs as you. That includes editor plugins, test helpers and package install scripts.

That last one is not hypothetical. In September 2025, the Shai-Hulud worm's install script harvested environment variables and ran TruffleHog to find secrets, Wiz reported.

A key on disk is only as safe as the least trusted code on the machine. Our guide to dApp frontend supply chain security covers that worm.

A leaked deployer key also takes whatever power it still holds. In December 2022, Rekt reported a compromise of Ankr's deployer key on BNB Chain.

The attacker upgraded the aBNBc token to a version that let them mint. Rekt put the exploiter's profit at about $5M, with copycats taking more through Helio.

Keystores and Hardware Wallets

Foundry's scripting docs label a keystore "recommended" and a raw --private-key "not recommended". Its best practices add a hardware wallet for mainnet.

Encrypted keystores

  1. Import the key once: cast wallet import deployer --interactive
  2. Sign with it by name: forge script script/Deploy.s.sol --account deployer --broadcast --rpc-url $RPC_URL
  • Import interactively. The key goes in at a prompt, not as a command argument.
  • Know where it lives. Encrypted keystores sit in ~/.foundry/keystores by default.
  • Use the hidden password prompt. Foundry calls a cleartext keystore password "unsafe".
  • Separate keys by environment. Foundry's split: Anvil keys locally, a keystore on testnets, a hardware wallet on mainnet.
  • Never reuse Anvil keys. In Foundry's words: "They are publicly known."

Hardware wallets and KMS

Swap --account for --ledger or --trezor, and the device signs each transaction. The key never sits on the laptop.

For deploys from CI, --aws and --gcp sign with a cloud KMS key, so no key file exists. The risk moves to whoever can call that key, so review that access too.

Simulate Before You Broadcast

Without --broadcast, forge script runs the script and, given an RPC URL, simulates every transaction against live state. Nothing is sent.

The simulated sequence is saved under broadcast/ in a dry-run folder. Read it: every address, argument and sender is there.

Then rehearse the real run on a fork. Foundry's docs are blunt: "An ordinary script containing several transactions is not one atomic operation."

  1. Start a fork: anvil --fork-url $RPC_URL --auto-impersonate
  2. Run against it: forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast --unlocked --sender $DEPLOYER

This sends the full sequence from the real deployer address, at its real nonce, with no key involved. Run your post-deploy checks against the fork too.

Three flags and one habit matter before the live run.

  • --slow sends each transaction only after the previous one is confirmed and succeeded.
  • --sender-nonce pins the starting nonce, so simulated CREATE addresses match the nonce you will broadcast from.
  • --resume finishes a failed broadcast from the saved plan. Edit the script and you must run it fresh.
  • Keep cache/ out of git. Foundry stores each transaction's RPC URL there, "which can contain an API key."

Proxies, Addresses and Arguments

Initialize in the constructor

Two calls in one script are still two transactions. A proxy deployed in one and initialized in the next is open to anyone in between.

Foundry's upgrade guide passes the encoded initializer to the proxy constructor, "so the initialize function is called atomically".

The pattern: new ERC1967Proxy(address(implementation), initData), where initData is the encoded initialize call.

Attackers have used that gap. In the CPIMP attacks Dedaub reported in July 2025, the owner's own initialize call still succeeded, so nothing looked wrong.

Nethermind's warning applies to scripts directly: putting deploy and initialize in "the same scripted flow" is "insufficient to mitigate the attack". Our pre-mainnet checklist covers how CPIMP worked.

Check every address on every chain

An address that is right on one chain can be empty, or someone else's, on another.

In June 2022, Wintermute gave Optimism a Safe address for a 20 million OP loan. The Safe existed on Ethereum, not on Optimism.

An attacker replayed Safe deployment transactions from Ethereum on Optimism and took control. When Wintermute posted, the attacker still held 19 million OP.

Wintermute's lesson: mainnet control "doesn't guarantee control on other EVM compatible chains."

Before a script hands a role to an address, run cast code against it on that chain, then read its owners and threshold.

Review every argument

Initializer arguments are configuration, and configuration can drain a bridge. A June 2022 upgrade initialized Nomad's Replica contract with 0x00 as a trusted root.

That made every message valid by default. In August, about $190M was drained, Rekt reported.

Log every address and argument with console.log, as Foundry's best practices show, and read them in the dry run before anything is sent.

Review and Verify the Deployment

Review the script

Treat the deploy script like contract code. Before it runs, confirm:

  • Every proxy gets its init data in the constructor.
  • Implementation constructors call _disableInitializers().
  • No key or address is hardcoded, per Foundry's own advice.
  • Ownership and admin roles move to the multisig inside the script.
  • The script ends with require checks on owners, roles and key parameters.
  • The deployer holds nothing when the script finishes.

Check what landed

Check what reached the chain, not what the script meant to do.

  • Bytecode. forge verify-bytecode checks deployed creation and runtime code against its verified source.
  • Compiler. Build from the audited commit. Foundry says to verify "with the exact compiler settings".
  • Proxies. cast implementation reads the EIP-1967 slot from chain state, not an explorer's proxy view.
  • Roles. Read every owner and role onchain with cast call.
  • Records. Commit the broadcast file. Each run-latest.json records the Git commit it ran from.

Then compare deployed source with the audited commit, file by file. Our study of deployed vs audited code shows the method.

How Guardian Helps

Guardian's offchain department is led by Carlos Polop, Head of Offchain Services and creator of HackTricks.

Its OpSec audit covers secrets handling, release and deployment control, and how transactions get signed. An infrastructure pentest tests whether a compromised pipeline "can ship or sign on your behalf".

Managed Security reviews every multisig transaction before it is signed, and every deployment and configuration change as it lands.

For USDT0, Guardian has verified deployments since February 2025, from byte-exact bytecode and multisig roles to fork tests with real deployment parameters.

Read the deployment verification case study.

Frequently Asked Questions

How do I deploy with Foundry without exposing my private key?

Import the key into an encrypted keystore with cast wallet import --interactive and pass --account to forge script, or sign with --ledger or --trezor. Foundry recommends a hardware wallet for mainnet and keeping .env files out of version control.

Does forge script send transactions without --broadcast?

No. Without --broadcast it runs the script and, with an RPC URL, simulates the transactions against live state without sending them. The simulated sequence is saved in a dry-run folder under broadcast/.

Is a Foundry script deployment atomic?

No. Foundry's docs say a script with several transactions "is not one atomic operation." Anything that must happen together, such as deploying and initializing a proxy, has to happen in one transaction, for example through the proxy constructor.

How do I check which implementation a proxy points to?

Run cast implementation on the proxy address. It reads the EIP-1967 implementation slot directly from chain state. Do not rely on an explorer's proxy view alone: in the CPIMP attacks, attackers fooled Etherscan into reporting the wrong implementation.

Sources

Get your next deployment reviewed

Guardian Managed Security reviews every deployment and configuration change as it lands, and every multisig transaction before it is signed.