Picture a product where users sign up with a normal social login, get a blockchain wallet without knowing it, and send transactions by clicking a button. No seed phrase, no browser extension, no scary "write down these 12 words or your money is gone forever" screen. To them it feels like any other app. Behind it sits a real on-chain account with a real private key.
The whole thing lives or dies on one question: if a backend is creating and holding private keys for every user, what happens when someone breaks in? A database full of user private keys is about the worst thing you can leak. So the bar is simple to state and annoying to actually meet: even the people running the service shouldn't be able to read the keys. Not the developers, not the on-call engineer at 2am, not an attacker who's popped a shell on the box, not whoever ends up with a copy of the database.
The usual way teams get this experience is to buy it. You integrate a Wallet-as-a-Service provider — Privy, Magic, Web3Auth, Turnkey, etc abd let them handle key creation, custody, and signing behind their SDK. That's a perfectly reasonable choice, and for a lot of teams it's the right one. But it comes with trade-offs: a third party now holds (or co-controls) your users' keys, you're tied to their pricing and uptime, your security story is only as good as their black box, and there's usually a per-wallet or per-signature bill that grows with you. This post is about the other path — building the whole thing yourself, in your own AWS account, so no outside vendor ever touches a private key and the trust boundary is entirely yours.
Doing that used to mean standing up and babysitting your own HSMs, which is exactly why most people reach for a WaaS in the first place. The shortcut is AWS Nitro Enclaves doing the sensitive work, with KMS acting as a bouncer that will only unlock a key for one specific, provable piece of code. You get HSM-grade key protection without running an HSM, on infrastructure you already have. I'll follow the user's journey — sign up, key gets made, log in, send funds — and bring in the security machinery at the points where it actually matters, rather than dumping it all up front.
If you're comfortable with OAuth, EC2, IAM and a bit of Python you'll be fine. The examples are deliberately chain-agnostic: wherever you'd otherwise load a raw private key (ed25519, secp256k1, whatever your chain uses) into memory to sign a transaction, the same pattern applies. Slot in your chain's SDK where the placeholders are.
Here's the shape of it before get into the weeds:
The one thing to hold onto: a plaintext private key only ever exists for a few milliseconds, and only ever in two places — inside a sealed enclave and inside a KMS hardware module. It never lands on the app servers, the database, the logs, or the wire. Not even when it's first created.
Signing up, and letting the enclave make the key
The front door is boring on purpose. User taps "sign up," an OpenID Connect flow runs through the identity provider (Cognito in front of the social providers works well here, but Auth0 or Google directly are the same idea), and back comes a JWT with the usual claims — sub, email, aud, iss, exp.
The sub claim is the important one. It's a stable unique ID for the user, and it becomes the handle everything else hangs off of.
Now, where do you generate the key? The obvious answer is a Lambda, and it works. But it has a nagging problem: for a few milliseconds the raw key sits in ordinary Lambda memory. Nobody's supposed to be able to read it, but "supposed to" is doing a lot of work in that sentence. The stronger option is to generate the key inside the enclave too. Then the raw key is born inside the sealed box and gets encrypted before anything ever leaves. There's no window, however tiny, where it exists somewhere it could theoretically be read.
The parent server just asks the enclave (over the local vsock channel — more on that soon) to make a wallet, and passes along some temporary AWS credentials so the enclave can reach KMS. This code runs inside the enclave:
import boto3
from botocore.config import Config
KMS_KEY_ID = "arn:aws:kms:us-east-1:123456789012:key/abcd-…"
def kms_client(creds: dict):
# The enclave has no network of its own; it reaches KMS through the
# local vsock proxy the parent runs (same path used for decryption).
return boto3.client(
"kms",
region_name="us-east-1",
aws_access_key_id=creds["access_key_id"],
aws_secret_access_key=creds["secret_access_key"],
aws_session_token=creds["token"],
config=Config(proxies={"https": "127.0.0.1:8000"}),
)
def create_wallet(creds: dict) -> dict:
"""Runs INSIDE the enclave. The plaintext private key never leaves this function."""
private_key, address = generate_keypair() # your chain's keygen (ed25519, secp256k1, …)
ciphertext = kms_client(creds).encrypt(
KeyId=KMS_KEY_ID,
Plaintext=private_key, # the raw private key material
)["CiphertextBlob"]
return {
"address": address, # public address, fine to hand back
"encrypted_key": ciphertext.hex(), # only ciphertext leaves the enclave
}
Once the enclave hands back the public address and the ciphertext, the parent's job is trivial: drop that ciphertext into Secrets Manager under the user's sub.
import json, socket, boto3
secrets = boto3.client("secretsmanager")
def create_account(token: str, aws_creds: dict) -> str:
claims = verify_jwt(token) # who is this user?
result = call_enclave({"op": "create", "credential": aws_creds})
secrets.create_secret(
Name=f"wallet/user/{claims['sub']}",
SecretString=json.dumps(result), # { address, encrypted_key }
)
return result["address"] # show the user their shiny new address
Where the key actually lives
The encrypted key goes into AWS Secrets Manager, one secret per user, named wallet/user/<sub>. What's stored there is already useless on its own — it's envelope ciphertext that can't be unwrapped without both KMS and the enclave, which is the whole point of the next section. So even if someone dumped every secret in the account, they'd have a pile of garbage.
Secrets Manager is a good fit here for a few reasons beyond just holding bytes. It re-encrypts everything at rest with its own KMS key, so the key ends up wrapped twice: once by the master key inside the enclave, then again by Secrets Manager. It gives you per-secret IAM, CloudTrail on every read, and versioning, all of which are nice to have when the thing you're storing is somebody's wallet. Scope the parent's role so it can only touch wallet/user/* and nothing else in the account.
One detail on cost and key management, since it comes up: use a single KMS master key to encrypt every user's private key. A private key is nowhere near KMS's 4 KB encryption limit, so there's no reason to mint a KMS key per user, and KMS bills per master key, so per-user keys would be financially silly. The per-user isolation that matters doesn't come from having millions of master keys anyway — it comes from each ciphertext being distinct and from the decrypt being gated. Which, finally, brings us to the interesting part.
Sending a transaction by logging in
This is the payoff and also where all the security machinery earns its keep. A returning user logs in, gets a fresh JWT, asks to send some funds, and the system signs the transaction with their key — without that key ever being visible to the servers. To believe that last clause you need to understand three things, so here they are roughly in the order you'd hit them.
The sealed box
A Nitro Enclave is a stripped-down virtual machine carved out of a normal EC2 instance. No disk, no network card, no SSH, and — this is the part that matters — no way for anyone to read its memory, including root on the parent instance. The only channel in or out is a local socket called vsock, addressed by a context ID (think of it like an IP) and a port. Only the key-handling code runs in there: creating wallets and signing. Everything else — the web server, JWT checks, Secrets Manager calls — runs on the parent, outside the box, which you should assume could get compromised.
PCR0, or how KMS knows it's really your code
For KMS to hand a key only to your signing code, it needs some way to recognize that code. That mechanism is PCR0.
PCR is short for Platform Configuration Register. When an enclave boots, the Nitro hypervisor measures the image as it loads it — hashing the contents into a set of registers that the enclave itself can't write to. If you've ever poked at a TPM and secure boot, it's the same idea. Only the hypervisor sets these values, at boot, which is exactly why you can trust them. There are a few of them:
PCR0 is a SHA-384 hash of the entire enclave image, kernel and app and all. That's the one you'll usually use. PCR1 covers just the kernel and bootstrap, PCR2 just the app without the kernel, and PCR8 is the certificate you signed the image with, if you signed it (handy if you'd rather pin "anything the build pipeline signed" than one exact hash).
The thing to internalize about PCR0 is that it's a content hash, so it's completely deterministic and completely unforgiving. Rebuild the exact same image and you get the exact same PCR0. Change one line, bump a dependency, nudge the base image, and it's a totally different value. You see it printed when you build:
nitro-cli build-enclave --docker-uri signing-server:latest --output-file signing_server.eif
# Measurements:
# "PCR0": "abacc679…cacd9fc0373ecd78c34c3e4cbf78ea9b5b0452a18"
That string is the fingerprint of your exact code, and it's what KMS checks before it will decrypt anything. Two things trip people up here, so consider yourself warned. First, because PCR0 changes on every code change, shipping a new enclave build means updating the KMS policy — allow both the old and new value during a rollout so you don't lock yourself out mid-deploy (signing images and pinning PCR8 is the cleaner long-term answer). Second, if you run with --debug-mode to see console output, attestation is disabled and all the PCRs go to zero. Great for local testing, a disaster if you point it at real keys.
Attestation, and the part everyone gets wrong
When the enclave asks KMS to decrypt, it doesn't send a plain API call. The Nitro SDK wraps the request in a signed attestation document that carries the PCR values and a one-time public key the enclave just generated. KMS checks that the document genuinely came from Nitro hardware and that PCR0 matches the condition in the key policy.
(Worth noting the asymmetry with sign-up: encrypting the key back during account creation needed no attestation at all — anyone with the right IAM can encrypt. Decrypting is the dangerous direction, so that's the one that's locked down.)
Here's the bit that's easy to get wrong, and almost everyone does at first: the decryption happens in two places, not one.
The real decryption happens inside KMS, not in the enclave. The master key never leaves the hardware module; KMS unwraps the ciphertext into the raw key on its side. But it obviously can't just send that plaintext back over the wire, since the reply travels through the untrusted parent to get to the enclave. So instead KMS re-encrypts the plaintext using that one-time public key from the attestation document and returns that (the CiphertextForRecipient field). Then a second decryption happens locally, inside the enclave, using the matching one-time private key that never left it.
You don't hand-roll any of this. The kmstool_enclave_cli binary that AWS ships does both halves — it builds the attested request (that's decryption #1, in KMS) and does the local unseal (decryption #2, in the enclave), and just hands you the plaintext.
The policy condition doing the enforcing is one line, kms:RecipientAttestation:ImageSha384, which reads as "only an enclave whose PCR0 equals this may decrypt":
{
"Sid": "Allow decrypt only from our enclave",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<ACCOUNT_ID>:role/ec2-instance-role" },
"Action": "kms:Decrypt",
"Resource": "*",
"Condition": {
"StringEqualsIgnoreCase": {
"kms:RecipientAttestation:ImageSha384": "<YOUR_PCR0_VALUE>"
}
}
}
So stealing the instance credentials gets you nowhere. Dumping every secret gets you nowhere. To decrypt a key you'd have to be the exact code, running inside a genuine enclave — and even that wouldn't help you, for reasons I'll come back to at the end.
Wiring it together
The full send path: verify the login JWT, pull the user's ciphertext out of Secrets Manager by sub, fill in the public chain data the transaction needs (nonce, fee, gas, chain ID — whatever your chain uses), then push the ciphertext and the unsigned transaction into the enclave to sign.
Verifying the login token on the parent is a few lines of PyJWT:
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://YOUR_IDP_DOMAIN/.well-known/jwks.json")
def verify_jwt(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="your-api-identifier",
issuer="https://YOUR_IDP_DOMAIN/",
) # ["sub"] == the user's Secrets Manager key
A note on stepping up to MFA for bigger transfers. The login JWT tells you who the user is, but for a large payment you want fresh proof that the actual human is behind this particular transaction, not just that they logged in an hour ago. The pattern for that is step-up authentication. The parent runs some business logic first — over some value threshold, say, or past a daily cumulative limit, or a destination the account has never sent to — and if the transaction trips that rule it refuses to sign and sends back a challenge instead. The app then prompts for MFA (a TOTP code, a passkey, a push), and on success the IdP mints a second JWT that's scoped to this one transaction: bound to the exact amount and destination, with an expiry measured in a minute or two so it can't be replayed on a different or later transfer. The parent checks both tokens — the session one for identity, the step-up one for this specific transaction — before anything reaches the enclave.
def require_step_up_if_needed(session_claims, amount, to, step_up_token): if amount <= LIMIT: # small tx: session JWT is enough return if not step_up_token: raise StepUpRequired() # tell the app to prompt for MFA mfa = verify_jwt(step_up_token) # same signature/claims checks if (mfa["sub"] != session_claims["sub"] # bind it to THIS user + THIS transfer or mfa.get("amount") != amount or mfa.get("destination") != to or "mfa" not in mfa.get("amr", [])): # amr = auth methods actually used raise Unauthorized()The nice thing is that it's a completely separate lock that slots in before the key machinery, and it doesn't touch any of it. The enclave, the attestation, the KMS policy — all unchanged. You've just made the "should this transfer happen at all" decision stricter for the transactions that deserve the extra friction.
The parent side, then, authenticates, loads the ciphertext, fills in the public chain data, and relays. It never touches a plaintext key:
import json, socket, boto3
secrets = boto3.client("secretsmanager")
chain = ChainClient("https://your-node.example:PORT") # your chain's RPC client
ENCLAVE_CID, ENCLAVE_PORT = 16, 5000
def call_enclave(payload: dict) -> dict:
s = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
s.connect((ENCLAVE_CID, ENCLAVE_PORT))
s.send(json.dumps(payload).encode())
result = json.loads(s.recv(8192).decode()); s.close()
return result
def send(token: str, to: str, amount, aws_creds: dict) -> str:
claims = verify_jwt(token) # 401 if invalid
record = json.loads(
secrets.get_secret_value(SecretId=f"wallet/user/{claims['sub']}")["SecretString"]
)
# Build the unsigned tx and fill in public chain data (nonce, fee, gas, chain id…).
unsigned_tx = build_tx(from_addr=record["address"], to=to, amount=amount)
unsigned_tx = fill_chain_params(unsigned_tx, chain) # public data, no key needed
signed = call_enclave({
"op": "sign",
"transaction": unsigned_tx,
"encrypted_key": record["encrypted_key"],
"credential": aws_creds, # temp instance creds
})
return chain.broadcast(signed["signed_tx"]) # submit; needs no key
Why does the enclave need AWS credentials handed to it? Because it has no network of its own. It borrows the parent's temporary instance credentials, tunneled through the same vsock proxy, to reach KMS. On their own those credentials are useless for this — without a valid attestation document, KMS just refuses to decrypt.
And the enclave itself, which is a single small server handling both the create from sign-up and the sign here:
import base64, json, socket, subprocess
VSOCK_PORT = 5000
def kms_decrypt(ciphertext_hex: str, creds: dict) -> bytes:
"""kmstool_enclave_cli does decryption #1 (KMS) and #2 (local unseal)."""
out = subprocess.run(
["/app/kmstool_enclave_cli",
"--region", "us-east-1", "--proxy-port", "8000",
"--aws-access-key-id", creds["access_key_id"],
"--aws-secret-access-key", creds["secret_access_key"],
"--aws-session-token", creds["token"],
"--ciphertext", ciphertext_hex],
capture_output=True, check=True,
).stdout.decode()
b64 = out.strip().split(":")[-1].strip() # "PLAINTEXT: <base64>"
return base64.b64decode(b64) # the raw private key
def handle(req: dict) -> dict:
if req["op"] == "create":
return create_wallet(req["credential"]) # from sign-up
if req["op"] == "sign":
private_key = kms_decrypt(req["encrypted_key"], req["credential"])
try:
signed_tx = sign_transaction(req["transaction"], private_key) # your chain's signer
return {"signed_tx": signed_tx}
finally:
del private_key # don't let plaintext outlive the request
return {"error": "unknown op"}
def serve():
s = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
s.bind((socket.VMADDR_CID_ANY, VSOCK_PORT)); s.listen()
while True:
conn, _ = s.accept()
req = json.loads(conn.recv(8192).decode())
conn.send(json.dumps(handle(req)).encode())
conn.close()
if __name__ == "__main__":
serve()
That's the entire trust boundary, and it's small enough to read in one sitting. The same sealed box both mints keys and signs with them, and in either case the plaintext key only exists inside the enclave — created in create_wallet or briefly revived in kms_decrypt, used once, and dropped before any reply crosses back over vsock. (generate_keypair and sign_transaction are the only two chain-specific pieces; everything else is identical no matter which blockchain you're on.)
Building and running it
The enclave app is a Docker image that you convert into an enclave image file:
FROM amazonlinux:2
RUN yum install -y python3 gcc python3-devel && pip3 install boto3 <your-chain-sdk>
COPY kmstool_enclave_cli /app/kmstool_enclave_cli
COPY libnsm.so /app/libnsm.so
COPY server.py /app/server.py
CMD ["python3", "/app/server.py"]
nitro-cli build-enclave --docker-uri signing-server:latest --output-file signing_server.eif
# ^ prints PCR0 → paste it into the KMS key policy
nitro-cli run-enclave --cpu-count 2 --memory 3806 \
--eif-path signing_server.eif --enclave-cid 16
One gotcha: enclaves aren't managed by Docker or systemd, so nothing restarts them for you if they die. A tiny watchdog under systemd that polls nitro-cli describe-enclaves and relaunches the enclave when it's gone covers this.
Wrapping up
Back at the start, the choice looked like "buy a Wallet-as-a-Service and hand off custody" or "run your own HSMs and hate your life." This is the third door. You get HSM-grade key protection out of infrastructure you already run, you own the entire trust boundary end to end, and the only two pieces that are chain-specific are generate_keypair and sign_transaction — everything else is the same whether you're on Ethereum, Solana, Bitcoin, or something that doesn't exist yet.
It's not a small amount of moving parts, and the attestation flow takes a beat to click. But once it does, the mental model is clean: social login decides who can act, the JWT and step-up MFA decide what they can do, PCR0 decides which code is allowed to touch a key, and the enclave plus KMS make sure the plaintext only ever exists somewhere nobody — including you — can reach into. That's a wallet you can put in front of ordinary users and still describe honestly: we made your key, we help you spend it, and we can't read it.
If you take one thing away, let it be that "non-custodial-ish, self-hosted, no vendor holding the keys" is not a moonshot anymore. It's a weekend spike on top of a reference implementation. So go build it.
More info — the full working reference implementation (VPC, KMS policy, enclave build, CDK) is open source: aws-samples/aws-nitro-enclave-blockchain-wallet. Clone it, deploy it, and read the parts this post glossed over.
Next steps — two directions worth exploring once the single-enclave version is working:
- Remove the single point of trust with MPC. Right now one enclave can reconstruct a whole key. Split it so no single enclave (or single AWS account) ever holds the complete key, and a signature needs several parties to cooperate: Build secure multi-party computation (MPC) wallets using AWS Nitro Enclaves.
- Make it production-grade. Add key backup and recovery (what happens if a user loses access?), multi-AZ high availability for the enclave and monitoring/alerting on failed attestations.






Top comments (0)