A wallet from a password: giving non-crypto users a real Ethereum account
Most users will never hold a seed phrase. Here's how I built a KDF bridge that derives a real, recoverable Ethereum wallet from a login, and the parts you have to get right.

Short answer: you can give someone a real Ethereum wallet without ever showing them a private key by deriving the key deterministically from something they already have, their email and password. A key derivation function (KDF) runs a slow hash over those inputs, you read 32 bytes out as the private key, and a library turns it into an address. The wallet is then encrypted twice, once with a key only the user's password can unlock and once with a platform key, so the user keeps control and the platform can still recover the account. The derivation is the easy part. Everything around it is the actual work.
I built this as the KDF module inside BlockAuth, an open-source (MIT) authentication package for Django REST Framework that bridges Web2 and Web3 login. The snippets below are lightly trimmed from the public repo, BloclabsHQ/auth-pack.
Why derive a wallet instead of generating one?
Because the alternative is handing a first-time user a seed phrase and asking them to guard it forever, and most people will not. If your product wants onchain features but your users have never heard of a wallet, a random key you generate is a key they will lose. Deriving the key from credentials they already manage means there is nothing new to write down.
The tradeoff is honest and worth stating up front: a derived wallet is only as strong as the inputs and the KDF behind it. That constraint shapes every decision that follows.
How do you turn a password into an Ethereum key?
Run a slow KDF over the login inputs and read 32 bytes out as the private key. In BlockAuth the default path is PBKDF2-HMAC-SHA256:
def derive_key(self, email: str, password: str, salt: str, master_salt: str = "") -> str:
# Normalize so the same person always derives the same key
email = email.lower().strip()
password = password.strip()
# The password is the secret; the per-user salt and platform
# master_salt scope the key to this user and this platform
kdf_input = f"{email}:{password}:{salt}:{master_salt}"
key_material = hashlib.pbkdf2_hmac(
"sha256",
kdf_input.encode("utf-8"),
salt.encode("utf-8"),
100_000, # iterations
dklen=32, # 32 bytes -> a 256-bit private key
)
return "0x" + key_material.hex()Those 32 bytes are a valid secp256k1 private key, so eth_account turns them straight into an address:
from eth_account import Account
private_key = service.derive_key(email, password, salt, master_salt)
address = Account.from_key(private_key).addressThe property that makes the whole idea work is determinism: the same email, password, and salts always produce the same key, so the wallet is not stored, it is re-derived on demand. Iteration count is a dial you set to your threat model: current guidance lands around 600,000 for PBKDF2, and memory-hard, GPU-resistant Argon2id is the stronger choice for anything holding real value. The presets here go there, up to Argon2id at 500,000 and 1,000,000 iterations.
How does the user keep control if the key is derived?
The derived key is never stored raw. It is encrypted twice, which is the part that decides your custody model. First with a key only the user's password can produce:
# 1) User encryption: only this user's password can derive this key
user_key = PBKDF2HMAC(
algorithm=SHA256(), length=32,
salt=user_salt.encode()[:16], iterations=100_000,
).derive(f"{email}:{password}:{user_salt}:user_encryption".encode())
user_blob = aes_256_gcm(private_key, user_key) # stored as nonce || tag || ciphertextThen the same private key is encrypted again under a platform master key:
# 2) Platform encryption: recoverable by the platform, no password needed
platform_blob = aes_256_gcm(private_key, MASTER_ENCRYPTION_KEY) # {encrypted_key, nonce, tag}Why both? The user-encrypted copy can only be opened with the user's password, which gives the user real control. The platform-encrypted copy can be opened without the password, which lets the platform recover an account or act on the user's behalf. That second copy is a deliberate custody decision: it means the platform can decrypt any wallet, which is right for a custodial or hybrid product and wrong for self-custody. Pick it on purpose, and say so to your users.
What happens when the user changes their password?
This is the trap that makes the naive version fail. The key is a function of the password, so if you simply re-derive after a password change, you get a different key, a different address, and the user has lost their old wallet and everything in it.
The fix is to treat the key as stored state, not something to recompute. On a password change you decrypt the key with the old password and re-encrypt it under the new one, keeping the address identical. BlockAuth exposes the seam for this as a trigger hook rather than hiding it:
BLOCK_AUTH_SETTINGS = {
"POST_PASSWORD_CHANGE_TRIGGER": "myapp.wallets.ReEncryptWalletTrigger",
}Being straight about scope: the library ships the hook and a worked example of the re-encryption, not turnkey automation. You own the trigger. The important part is the rule it enforces, re-encrypt, never re-derive.
What can go wrong, and did?
Determinism is the hardest promise in the whole system, because it can break for reasons that have nothing to do with your code.
- Determinism is a promise you can break by accident. The address is a pure function of the derivation, so anything that changes the derivation, a different algorithm, a fallback path, or a crypto library that is not installed, changes the resulting wallet. Treat your KDF algorithm and crypto dependencies as consensus-critical: pin them, and never let the derivation silently fall back to a weaker path.
- A weak password is a weak wallet. There is no seed phrase to fall back on, so the password and the iteration count are the whole security budget. Raise iterations toward current guidance, or use Argon2id for anything with value.
- Lose the master key, lose the wallets. If the platform master key is unset or ephemeral, the platform-encrypted copies become undecryptable. Treat it like an HSM secret, not an environment variable you regenerate.
Key takeaways
- A KDF bridge lets non-crypto users hold a real wallet: derive the key from their login, never show them a seed phrase.
- Dual-encrypt the derived key, once with a user-password key for control and once with a platform key for recovery, and choose that custody model deliberately.
- On a password change, re-encrypt the stored key. Never re-derive, or you orphan the wallet and its assets.
- Determinism is a systems problem. Pin your crypto dependencies and configuration, or the same password will produce different wallets across environments.
Questions you might have next
- Is this self-custody? No. The platform-encrypted copy means the platform can decrypt any wallet, so this is a custodial or hybrid model by design. Drop the platform copy if you want the user's password to be the only key, and accept that account recovery goes with it.
- What about account abstraction or ERC-4337? The KDF produces a plain externally owned account. Smart-account features are a seam you add on top, for example by attaching a smart-account address as a custom JWT claim, not something the bridge itself ships.
- What iteration count should I use? The default is 100,000 PBKDF2 iterations for standard use; the higher presets move to Argon2id at 500,000 and 1,000,000. For anything holding value, start at the top and measure.
Onboarding is one of three seams that decide whether an onchain product ships. For the full map, see building production Web3 apps.