How to Sign a Transaction Offline and Broadcast It with eth_sendRawTransaction

To sign an Ethereum transaction offline, you build the transaction, sign it locally with your private key, and get back a serialized blob of hex. Then you hand that blob to the network with eth_sendRawTransaction, which checks it and drops it into the mempool. The signing and the sending are two separate acts, and the private key only takes part in the first one. It never travels anywhere.

That separation is the whole point. This guide covers why you would sign a transaction this way, what the signed object actually contains, and how to go from a plain-text transaction to a confirmed one using web3.py, ethers.js, or a raw JSON-RPC call.

Why Sign a Transaction Offline at All?

There are two ways to get a transaction onto Ethereum. The first is eth_sendTransaction, where you hand an unsigned transaction to a client that holds your private key, and it signs and submits in one step. The second is to sign the transaction yourself and submit the finished product with eth_sendRawTransaction.

The first method only works when the client controls the key. A local Geth instance with an unlocked account can do it. A hosted endpoint cannot, and will not, because it never has your key in the first place. Send eth_sendTransaction or eth_signTransaction to most API providers and the call is rejected, since there is nothing on their side to sign with.

So anyone building against a hosted endpoint signs locally by default. There is no other option. But even teams that could unlock an account on their own machine usually avoid it, because keeping a key unlocked on an internet-facing server is a standing liability. Signing locally keeps the key on hardware you control.

Offline signing takes that idea further. You can prepare and sign a transaction on a machine that has never touched the internet, move only the signed hex to a connected machine, and broadcast it there. The key stays air-gapped. This is how hardware wallets and cold-storage setups move funds without ever exposing the secret that controls them.

What “Signing” an Ethereum Transaction Actually Does

A signed transaction is the set of transaction fields plus an ECDSA signature that proves the holder of one specific private key authorized those exact fields. Change a single value after signing — the amount, the recipient, the gas limit — and the signature no longer matches, so the network rejects it.

This is why a broadcasting endpoint can refuse to relay your transaction but can’t tamper with it. It can drop the request, but it cannot bump your gas, redirect your funds, or alter the recipient, because any edit invalidates the signature it would then have to forward.

The signature also pins the transaction to one chain. EIP-155 folds the chain ID into the signed data, so a transaction signed for Ethereum mainnet (chain ID 1) can’t be replayed on a testnet or an EVM-compatible chain that uses a different ID. When you sign offline, you are responsible for setting that chain ID correctly, because there is no connected client to fill it in for you.

The output of signing is a raw transaction: the fields and signature serialized with RLP encoding into a single 0x-prefixed hex string. That string is self-contained. It carries everything the network needs to verify and execute the transaction, which is exactly why it can be generated on an offline machine and carried anywhere.

Signing is pure computation. It is math over the transaction data and the key, with no call to any server. That is what makes offline signing possible at all.

What Goes Into the Transaction You Sign

Before you can sign anything, you need a complete transaction object. For a standard transfer on today’s network, that means a type 2 (EIP-1559) transaction with these fields:

FieldWhat it is
nonceThe sender’s transaction count, so the network orders your transactions and rejects duplicates
toThe recipient address, or empty for a contract deployment
valueAmount of ETH to send, in wei
dataCall data for a contract, or empty for a plain transfer
gas / gasLimitMaximum gas units the transaction may consume (21,000 for a simple transfer)
maxFeePerGasThe most you’ll pay per gas unit, base fee included
maxPriorityFeePerGasThe tip per gas unit that goes to the validator
chainIdWhich chain the transaction is valid on (1 for Ethereum mainnet)
typeThe transaction format — 2 for EIP-1559

EIP-1559 replaced the single gasPrice field with the maxFeePerGas and maxPriorityFeePerGas pair. The base fee is set by the network and burned; your tip is what incentivizes a validator to include you. Type 2 is the common format for normal transfers now, but it isn’t the only one. Legacy transactions (type 0) still work and use a flat gasPrice. There are also type 1 access-list transactions (EIP-2930), type 3 blob transactions used mostly by Layer 2 rollups (EIP-4844), and, since the Pectra upgrade, type 4 transactions (EIP-7702) that let a regular account temporarily act like a smart contract wallet.

The one field people get wrong is nonce. It has to equal the sender’s current transaction count. Read it from the network with eth_getTransactionCount using the "pending" tag so it includes any transactions you’ve already sent but that haven’t been mined yet. Guess it and the network will reject the result.

How to Sign and Broadcast, Step by Step

The flow is the same no matter which library you use:

  1. Read the chain data you need. Fetch the current nonce, chain ID, and fee levels. This is the one part that needs a connection.
  2. Build the transaction object with the fields above.
  3. Sign it locally with the private key. No network call happens here.
  4. Take the serialized raw hex the signing step returns.
  5. Broadcast it with eth_sendRawTransaction.
  6. Poll for the receipt to confirm it was mined.

Only step 1 and step 5 touch the network, and they can run on a different machine than steps 2 through 4. That gap is where the security benefit lives.

Signing with web3.py (Python)

The web3.py transactions guide uses w3.eth.account.sign_transaction to sign and send_raw_transaction to broadcast:

python

import os
from web3 import Web3

w3 = Web3(Web3.HTTPProvider("YOUR_ETHEREUM_ENDPOINT"))

pk = os.environ["PRIVATE_KEY"]          # load the key from the environment, never from code
acct = w3.eth.account.from_key(pk)

tx = {
    "type": 2,
    "chainId": 1,
    "to": "0xRECIPIENT_ADDRESS",
    "value": w3.to_wei(0.001, "ether"),
    "nonce": w3.eth.get_transaction_count(acct.address, "pending"),
    "gas": 21000,
    "maxFeePerGas": w3.to_wei(30, "gwei"),
    "maxPriorityFeePerGas": w3.to_wei(1.5, "gwei"),
}

signed = w3.eth.account.sign_transaction(tx, pk)   # offline: pure signing, no RPC call
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)  # broadcast
print(w3.to_hex(tx_hash))

Two details matter here. The signed object exposes its serialized bytes as signed.raw_transaction (snake_case in current web3.py — older code used rawTransaction). And sign_transaction itself needs no connection, so on an air-gapped setup you run it on the offline machine and carry signed.raw_transaction across to broadcast.

Signing with ethers.js v6 (JavaScript)

In ethers v6, a Wallet created without a provider signs offline, and provider.broadcastTransaction is the v6 name for submitting the raw result:

javascript

import { Wallet, JsonRpcProvider, parseEther, parseUnits } from "ethers";

const provider = new JsonRpcProvider("YOUR_ETHEREUM_ENDPOINT");
const wallet = new Wallet(process.env.PRIVATE_KEY);   // no provider attached = offline signer

const tx = {
  type: 2,
  chainId: 1,
  to: "0xRECIPIENT_ADDRESS",
  value: parseEther("0.001"),
  nonce: await provider.getTransactionCount(wallet.address, "pending"),
  gasLimit: 21000n,
  maxFeePerGas: parseUnits("30", "gwei"),
  maxPriorityFeePerGas: parseUnits("1.5", "gwei"),
};

const rawTx = await wallet.signTransaction(tx);        // returns the 0x-prefixed signed hex
const sent = await provider.broadcastTransaction(rawTx); // eth_sendRawTransaction under the hood
console.log(sent.hash);
await sent.wait();                                      // wait for the receipt

wallet.signTransaction(tx) gives you the raw hex string directly. If you want a true air-gap, generate the nonce and fee values ahead of time, build the object on the offline machine, and only broadcastTransaction from a connected one.

The raw JSON-RPC call

Under both libraries, the broadcast is a single JSON-RPC request. You can send it yourself once you have the signed hex:

bash

curl -X POST https://eth.nownodes.io \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "eth_sendRawTransaction",
    "params": ["0xSIGNED_RAW_TRANSACTION_HEX"]
  }'

The params array holds exactly one item: the raw signed transaction. Nothing about the key or the individual fields appears in the request, because they’re all baked into that hex string already.

What eth_sendRawTransaction Returns, and What It Doesn’t

A successful call returns a 32-byte transaction hash right away. That hash is a receipt that the network accepted your transaction into the mempool — not confirmation that it was mined. As the RPC method reference puts it, the hash is “not proof that it has been mined.”

To know the transaction actually landed, poll eth_getTransactionReceipt with that hash until it returns a non-null result, then check the receipt’s status field. A status of 0x1 means success; 0x0 means the transaction was included but reverted, and you still paid for the gas it burned.

This two-stage reality — accepted, then mined — is why production code almost always waits for the receipt rather than trusting the hash alone.

Common Errors and What They Mean

Most failures at broadcast time come from the transaction being built slightly wrong, not from the network being down. These are the ones you’ll see most:

ErrorCauseFix
nonce too lowThe signed nonce is at or below the account’s current on-chain countRe-read the nonce with eth_getTransactionCount using "pending", then re-sign
insufficient funds for gas * price + valueThe balance can’t cover the transfer plus maximum gas costLower the value or the fee cap, or fund the account
replacement transaction underpricedYou reused a pending nonce but didn’t raise the fee enoughBump maxFeePerGas and maxPriorityFeePerGas by at least ~10%
already knownThe exact signed transaction is already in the mempoolWait for it to mine instead of resubmitting

The recurring theme is the nonce. Because you set it yourself when signing offline, it’s the field most likely to drift out of sync — especially when you send several transactions in a row and read "latest" instead of "pending" between them.

Where NOWNodes Fits

Signing is self-contained, but two steps still need a live connection to the network: reading the nonce and fees before you sign, and broadcasting the signed result afterward. That connection is an Ethereum endpoint, and running the client behind it yourself means syncing and maintaining a full archive of the chain.

NOWNodes provides a hosted Ethereum endpoint so an application can read account state and broadcast signed transactions over an API, without operating its own client. The endpoint never holds your key — it signs nothing — which is why eth_sendRawTransaction is the method you use against it. It relays the signed hex you built locally, and because the signature covers every field, the endpoint can forward or reject the transaction but can’t change it.

For testing the flow before going near mainnet funds, the public endpoints offer keyless access for light reads, and the free Start plan includes 100,000 requests once you need an API key. A sensible order is to sign and broadcast against a testnet first, confirm the receipt status comes back as success, then switch the chain ID and endpoint to mainnet. The code doesn’t change; only the target does.

Conclusion

Signing offline and broadcasting with eth_sendRawTransaction comes down to keeping two jobs apart. Signing is local math that turns your transaction and key into a tamper-proof blob of hex, and it needs no network at all. Broadcasting is a single JSON-RPC call that pushes that blob into the mempool and hands you a hash. Keep the key on the signing side of that line, set the nonce and chain ID yourself with care, and wait for the receipt rather than trusting the hash, and you have a transaction flow that works the same whether you’re sending from a script, a backend, or an air-gapped cold wallet.

FAQ

What’s the difference between eth_sendTransaction and eth_sendRawTransaction?

eth_sendTransaction asks the client to sign for you, which only works if it holds your unlocked key. eth_sendRawTransaction takes a transaction you already signed yourself and just relays it. Hosted endpoints support the second because they never hold your key.

Does signing a transaction cost gas?

No. Signing is a local operation with no network interaction, so it’s free. Gas is only spent when the transaction is mined, and it’s charged against the sender’s balance at that point — not when you sign.

Can the endpoint I broadcast through steal or change my transaction?

It can refuse to relay the transaction, but it can’t alter it. Every field is covered by your signature, so any edit — to the amount, recipient, or gas — invalidates the signature and the network rejects the result.

Why does my transaction keep failing with “nonce too low”?

The nonce you signed is at or below the account’s current count, usually because it was read as "latest" while an earlier transaction was still pending. Re-read the count with the "pending" tag and sign again.

Do I need an archive endpoint to broadcast a signed transaction?

No. Broadcasting only needs a standard endpoint that can accept eth_sendRawTransaction and read current account state. Archive access matters for querying old historical state, not for sending new transactions.