Python 3, a terminal, and one library. No account of yours is touched, nothing is
registered anywhere, and nothing connects to the internet. macOS and Linux include
Python; on Windows install it from python.org with
“Add python.exe to PATH” ticked. Install the library once with
python3 -m pip install cryptography.
You need no cryptography background. Only one idea is used throughout: a key pair is two matching halves, where anything signed by the private half can be checked by the public half, and the public half reveals nothing about the private one. Run the five files in order — step 1 writes a small file the rest read.
The Problem Passwords Cannot Solve
Every password shares one fatal property: to prove you know it, you have to send it to
whoever is asking. If the thing asking is a convincing fake, you have just given your secret
to an attacker. No amount of length or complexity changes this. A sixty-character password
typed into a phishing page is exactly as compromised as 123456.
Two-factor authentication was the patch for this, and it helped enormously. But the common forms are still things you can be persuaded to hand over -- an SMS code, an app code, an approval tap. Adversary-in-the-middle phishing kits now relay your login to the real site in real time, so your genuine code works, and the attacker captures the resulting session.
Passkeys fix the underlying flaw rather than adding another layer on top. The industry has moved decisively: the FIDO Alliance reported roughly five billion passkeys in active use by 2026, Google has enabled them by default for eligible accounts, and Microsoft is making passkeys the default sign-in method in Entra ID while retiring SMS and voice authentication from September 2026.
How a Passkey Works
A passkey is a pair of cryptographic keys. When you create one, your device generates both halves. The public key goes to the website. The private key never leaves your device, and on most hardware it is held in a dedicated security chip that will not export it at all.
To log in, the site sends a random challenge. Your device unlocks the private key with your fingerprint, face, or device PIN, signs the challenge, and returns the signature. The site verifies it with the public key it already holds. The secret itself is never transmitted, so there is nothing in transit to steal.
Biometrics unlock the local key; they are not sent to the website. The site learns only that your device successfully authorised the signature. This is a common and understandable worry, and the answer is genuinely reassuring.
Why It Cannot Be Phished
This is the part that makes passkeys categorically different, and it is worth understanding precisely.
- The passkey is bound to the exact domain that created it. Your browser
will not offer a passkey for
example.comto a site atexamp1e.com. Not as a warning you can dismiss -- it simply does not happen - There is no secret to type, so there is nothing to enter into a fake form, however convincing it looks
- Relaying does not work -- the signature is tied to the domain that requested it, so an adversary-in-the-middle proxy cannot forward it to the real site
- Nothing reusable is stored on the server -- a breach exposes public keys, which are useless to an attacker. There is no hash to crack and no credential to stuff
- Nothing to reuse across sites -- every passkey is unique by construction
In short, passkeys remove human judgement from the security decision. You no longer have to correctly spot a lookalike domain at seven in the morning, because your browser does it for you and cannot be talked out of it.
Setting One Up
Email is the recovery channel for everything else, so it deserves the strongest protection you have. Then do your password manager, then banking, then social and shopping accounts.
Look for "Passkeys", "Sign in without a password", or "Security keys" under the account's security section. Choose to create one and approve with your fingerprint, face, or device PIN.
This is the step people skip and later regret. One passkey on one phone is a single point of failure. Add one on a laptop, a tablet, or a hardware security key.
Apple, Google and Microsoft sync passkeys through their own ecosystems. Most major password managers now store them too, which is the better choice if you use more than one platform -- your passkeys then follow you across Windows, Android, macOS and iOS.
Sign out and sign back in with the passkey. Verify it works on every device you registered, while you still have your old login method available.
Do Not Lock Yourself Out
Passkeys are safer than passwords, but the failure mode is different. A forgotten password can be reset by email; a lost device with the only passkey on it is a harder problem.
- Always register at least two -- on separate devices, or one device plus a hardware security key kept somewhere safe
- Save the account's backup codes -- print them or store them in your password manager, offline and away from the device
- Confirm your recovery email and phone are current before you remove other sign-in methods
- Know how your sync provider recovers -- if passkeys sync through an account, losing access to that account affects all of them. Make sure that account itself has solid recovery
- Do not delete your password immediately -- run both for a few weeks first. Removing the fallback on day one is the most common way people get stuck
If the account still allows SMS reset, an attacker who takes over your phone number can bypass the passkey entirely. After you are comfortably established on passkeys, remove SMS as a login and reset method wherever the service permits it -- otherwise you have added a strong lock beside an open window.
Honest Limitations
Passkeys are the biggest practical improvement in account security in years, and they are not magic.
- Support is still uneven -- many smaller sites do not offer them yet, so you will keep a password manager for a long time
- Cross-ecosystem use can be awkward -- a passkey in Apple's keychain used on a Windows machine typically means scanning a QR code with your phone. It works, but it is a step
- Device compromise still matters -- malware that already controls your unlocked device is a different threat model, though the key itself remains non-exportable
- They do not protect an already-open session -- a stolen session cookie bypasses login entirely, which is why revoking sessions after any incident still matters
- Account recovery is the soft underbelly -- a service with weak recovery undermines any login method, passkeys included
Build a Passkey From Scratch, in Five Steps
Passkeys are usually explained by what they replace — “no more passwords” — which tells you nothing about why they are harder to steal. The explanation is short and worth having: your device keeps a private key it never sends, the website keeps a public key that is useless on its own, and signing in means proving you hold the private half for that particular site, at this particular moment. In the next twenty minutes you will build all three parts, breach the website and find nothing worth stealing, catch a cloned key, and think through the objection that actually matters — losing the device. Every line of output below came from running these files.
Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.
Do: save this as register.py and run python3 register.py. The
seed is fixed so your key matches the one printed here; a real device generates a random one that
cannot be exported.
"""Registration: the device makes a key pair and keeps half of it forever."""
import json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
# On a real phone this happens inside the secure element and cannot be exported.
private = Ed25519PrivateKey.from_private_bytes(bytes(32)) # fixed seed for reproducibility
public = private.public_key()
public_bytes = public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw)
# What the WEBSITE stores. This is the whole record.
server_record = {
"user": "sarah",
"origin": "https://bank.example",
"public_key": public_bytes.hex(),
"sign_count": 0,
}
json.dump(server_record, open("server.json", "w"), indent=2)
print("stays on your device (never transmitted): the private key")
print("sent to the website : the public key")
print()
print(json.dumps(server_record, indent=2))
print()
print("There is no password in that record, and no secret. If the website is")
print("breached tomorrow, this is what the attacker gets.")
You should see: the entire record a website stores about your passkey:
stays on your device (never transmitted): the private key
sent to the website : the public key
{
"user": "sarah",
"origin": "https://bank.example",
"public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
"sign_count": 0
}
There is no password in that record, and no secret. If the website is
breached tomorrow, this is what the attacker gets.
Four fields, and not one of them is a secret. That is the structural difference from a password: with a password, the thing that proves who you are has to be sent to the website at least once, and the website has to keep something derived from it. With a passkey, the proving half never leaves the device that made it.
Note the origin field. The site name is part of the record from the beginning,
which is what makes step 2 possible.
If not: ModuleNotFoundError: No module named 'cryptography' means the library
is missing — install it with python3 -m pip install cryptography. Your
public_key should be identical to the one above; a different value means the
bytes(32) seed was altered.
Go: the same folder.
Do: save this as authenticate.py and run python3 authenticate.py.
"""Signing in: a fresh challenge, signed together with the site's name."""
import json, secrets
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PublicKey)
from cryptography.exceptions import InvalidSignature
private = Ed25519PrivateKey.from_private_bytes(bytes(32))
record = json.load(open("server.json"))
public = Ed25519PublicKey.from_public_bytes(bytes.fromhex(record["public_key"]))
def server_challenge():
return secrets.token_bytes(32)
def device_signs(challenge, origin_the_browser_reports):
return private.sign(challenge + b"|" + origin_the_browser_reports.encode())
def server_verifies(signature, challenge):
try:
public.verify(signature, challenge + b"|" + record["origin"].encode())
return "signed in"
except InvalidSignature:
return "REFUSED"
c = server_challenge()
print("challenge length :", len(c), "bytes, new every time")
print()
print("on the real site :", server_verifies(device_signs(c, "https://bank.example"), c))
print("on a lookalike site :", server_verifies(device_signs(c, "https://bank-verify.example"), c))
print("replaying yesterday's:", server_verifies(device_signs(b"an old challenge", "https://bank.example"), c))
print()
print("Three things must line up: the right key, this challenge, and this")
print("origin. Miss any one and the signature does not verify.")
You should see: one success and two refusals:
challenge length : 32 bytes, new every time
on the real site : signed in
on a lookalike site : REFUSED
replaying yesterday's: REFUSED
Three things must line up: the right key, this challenge, and this
origin. Miss any one and the signature does not verify.
Each refusal closes a different attack. Signing on a lookalike site fails because the browser reports the real origin to the key, and the signature therefore covers a name the genuine site does not accept — so a phishing page cannot obtain anything it can use, even if you are completely taken in. Replaying an old signature fails because the challenge is new every time, so a captured signature is worth nothing a second later.
Both of those are automatic. There is no moment at which the user is asked to check a domain, compare a code or notice anything — which is the whole point, because people are bad at all three under time pressure.
If not: if the lookalike case prints signed in, the verifier is checking the
origin passed to the device rather than the one stored in server.json; the server
must always verify against its own name. If every case is refused, re-run step 1 so the stored
public key matches this key pair.
Go: the same folder.
Do: save this as breach.py and run python3 breach.py.
"""The website is breached. Compare what the attacker gets."""
import hashlib, json
PASSWORD_DB = [
("sarah", hashlib.sha256(b"correct-horse-battery").hexdigest()),
("james", hashlib.sha256(b"Summer2026!").hexdigest()),
]
PASSKEY_DB = [json.load(open("server.json"))]
print("STOLEN PASSWORD DATABASE")
for user, h in PASSWORD_DB:
print(" %-8s %s" % (user, h[:32] + "..."))
print(" what the attacker can do: run a word list against these offline,")
print(" for as long as he likes, and try every hit on every other site.")
print()
print("STOLEN PASSKEY DATABASE")
for rec in PASSKEY_DB:
print(" %-8s %s" % (rec["user"], rec["public_key"][:32] + "..."))
print(" what the attacker can do: verify signatures that already exist.")
print(" To sign in he needs the private key, which was never on the server")
print(" and cannot be derived from this. There is nothing to crack.")
print()
print("offline attacks possible against the password database :", "yes")
print("offline attacks possible against the passkey database :", "no")
print()
print("This is the property nothing else on the list has: the website")
print("cannot leak your credential, because it never had it.")
You should see: two stolen databases with very different value:
STOLEN PASSWORD DATABASE
sarah 62249369389075490555a758353aec61...
james a5c95e887dbf1c0a49fd01bced847d64...
what the attacker can do: run a word list against these offline,
for as long as he likes, and try every hit on every other site.
STOLEN PASSKEY DATABASE
sarah 3b6a27bcceb6a42d62a3a8d02a6f0d73...
what the attacker can do: verify signatures that already exist.
To sign in he needs the private key, which was never on the server
and cannot be derived from this. There is nothing to crack.
offline attacks possible against the password database : yes
offline attacks possible against the passkey database : no
This is the property nothing else on the list has: the website
cannot leak your credential, because it never had it.
A stolen password database is an asset with a long life: the attacker takes it away, attacks it offline at whatever speed he can afford, and every password he recovers gets tried on every other service the victim uses. That is the mechanism behind most account takeovers years after a breach.
A stolen passkey database is a list of public keys. There is no offline attack against it, because there is no secret in it — and the keys are per-site, so even a recovered one would be useless anywhere else. The website cannot leak your credential, because it never had it.
If not: FileNotFoundError: server.json means step 1 has not been run in this
folder. The password hashes shown are plain sha256 for illustration; a real site
should use a slow password hash, which improves the first column without changing the
comparison.
Go: the same folder.
Do: save this as counter.py and run python3 counter.py.
"""The counter that notices a cloned authenticator."""
import json
record = json.load(open("server.json"))
record["sign_count"] = 0
def sign_in(count_from_device, label):
stored = record["sign_count"]
if count_from_device <= stored:
print(" %-34s count %-3d (server has %d) -> REJECTED, possible clone"
% (label, count_from_device, stored))
return False
record["sign_count"] = count_from_device
print(" %-34s count %-3d accepted" % (label, count_from_device))
return True
sign_in(1, "your phone, Monday")
sign_in(2, "your phone, Tuesday")
sign_in(3, "your phone, Wednesday")
sign_in(2, "a copy of the key, made Tuesday")
sign_in(4, "your phone, Thursday")
print()
print("A genuine authenticator's counter only ever goes up. A copy taken at")
print("count 2 keeps counting from 2, so the first time it is used after the")
print("original has moved on, the server sees a number it has already passed.")
You should see: the copy rejected on a number the server has already seen:
your phone, Monday count 1 accepted
your phone, Tuesday count 2 accepted
your phone, Wednesday count 3 accepted
a copy of the key, made Tuesday count 2 (server has 3) -> REJECTED, possible clone
your phone, Thursday count 4 accepted
A genuine authenticator's counter only ever goes up. A copy taken at
count 2 keeps counting from 2, so the first time it is used after the
original has moved on, the server sees a number it has already passed.
The counter is a small, elegant addition to the scheme. Each authenticator increments its own counter on every signature, and the server refuses any value it has already passed. A perfect copy of the key material therefore still betrays itself, because two devices counting independently from the same starting point cannot both keep going up from the server's point of view.
It is a detection mechanism, not a prevention one — and worth knowing that synced passkeys, the kind that follow you between your own devices, generally do not use it, because syncing is exactly the thing it is designed to notice. That is a deliberate trade of this detection for the recoverability discussed next.
If not: if the clone is accepted, the comparison was written as < rather than
<= — a repeated value must be rejected too, not only a lower one.
Go: the same folder.
Do: save this as lost.py and run python3 lost.py.
"""What happens when the device is lost -- the real objection to passkeys."""
SCENARIOS = [
("synced passkey (iCloud / Google / a password manager)",
"restored on the new device from your account", "you can sign in"),
("device-bound passkey, and you registered a second device",
"the second device still has its own key", "you can sign in"),
("device-bound passkey, and it was your only one",
"the private key is gone with the device", "account recovery required"),
]
print("%-56s %s" % ("SITUATION", "OUTCOME"))
print("-" * 84)
for what, why, outcome in SCENARIOS:
print("%-56s %s" % (what, outcome))
print("%-56s (%s)" % ("", why))
print()
print("recoverable without falling back to email or SMS:", 2, "of", len(SCENARIOS))
print()
print("The lesson is the same as for any key: have two. Register a second")
print("device or a second security key on the day you set the first one up,")
print("because the fallback path is the weakest part of the whole system.")
You should see: two situations that recover cleanly and one that does not:
SITUATION OUTCOME
------------------------------------------------------------------------------------
synced passkey (iCloud / Google / a password manager) you can sign in
(restored on the new device from your account)
device-bound passkey, and you registered a second device you can sign in
(the second device still has its own key)
device-bound passkey, and it was your only one account recovery required
(the private key is gone with the device)
recoverable without falling back to email or SMS: 2 of 3
The lesson is the same as for any key: have two. Register a second
device or a second security key on the day you set the first one up,
because the fallback path is the weakest part of the whole system.
“What if I lose my phone?” is the right question to ask, and the honest answer depends on which kind of passkey you have. A synced passkey lives in your platform account or password manager and reappears on a new device when you sign in to that. A device-bound passkey — a hardware security key, or one created with that option — does not, which is stronger against theft of the sync account and worse against losing the object.
The practical advice is the same either way: register two. Two devices, or a device and a hardware key, on the day you set it up. Otherwise the recovery route becomes email or SMS — and you will have replaced a phishing-resistant login with a phishing-resistant login that has a phishable back door, which is the weakness the whole design was meant to remove.
If not: this prints a fixed table and cannot really fail; if the count is not
2, a row's outcome string was reworded, since the counter is a literal in the last
lines.
Without scrolling up: a colleague says passkeys are just passwords stored on your phone, so if the phone is unlocked by someone else they have everything, and if the website is hacked the passkeys leak like passwords do. Which half of that is right, and which is wrong? Answer: the second half is wrong. Step 3 showed the website holds only public keys, which contain no secret and support no offline attack — there is nothing in that database to crack, and nothing that would work on another site even if there were, because keys are generated per site. The first half has something to it, but is imprecise: a passkey is protected by the device's own unlock, so someone who has both your unlocked phone and physical possession of it can indeed sign in, exactly as they could open your password manager. The difference is that this requires holding your device, which is a targeted physical attack against one person, rather than a remote one that scales to millions — and step 2 showed that the remote routes, phishing and replay, do not work at all. What would genuinely worry me instead is the recovery path: if losing the phone drops the account back to an emailed code, the account is only as strong as that.
Now do it without the page: extend counter.py so the server, instead of merely rejecting a
suspicious count, records the event and flags the account for review — then decide what a
site should actually do when it sees one, given that a legitimate cause exists (a restored
backup). Then do the real-world step: pick one account that supports passkeys, add one, and
immediately add a second on a different device. The second one is the part people skip and the
part that matters.
Summary
- Passwords must be transmitted to be proven -- that is the flaw passkeys remove
- The private key never leaves your device, so there is nothing to steal in transit or from a server breach
- Domain binding blocks phishing structurally -- your browser will not offer the key to a lookalike site
- Register at least two, and keep backup codes -- lockout is the real risk, not theft
- Remove SMS reset once established, or the weakest path still decides your security
- Keep a password manager -- adoption is broad but far from universal
Add a passkey to your main email, then add a second one on another device. Those two steps take about five minutes and remove the single most valuable account you own from the reach of every phishing page on the internet.