*I Built My Own Passkey Manager and Now I Understand Why No One Does It on the Fly

14 min readAugust 31, 2026

The story of building passkey-vault: what hurts most in WebAuthn and what decisions I had to make as a founder, not just as a programmer.

Topics: passkeys · webauthn · security · fido2

Introduction

Hey there! It started with frustration, as usual. I try to log in with a passkey, the browser asks which password manager should handle it, I pick one, wait, something blows up, I try again on my phone, scan a QR code, nothing. Again. Fuck. After the third such session in a week I thought: this isn’t a “works‑most‑of‑the‑time” tech. It has to work every time because it’s a login flow. So instead of cursing another product I opened an empty folder and started writing my own.

Thus passkey-vault was born. This post isn’t a changelog or a step‑by‑step tutorial. It’s the stuff nobody tells you before you sit down with WebAuthn: the demo on the vendor site and a real‑world implementation are two different worlds, and most of the hard decisions in this project weren’t technical at all: they were founder decisions.

You’ll get three things:

  1. What actually hurts in WebAuthn when you stop copying the example from the spec and start building something that has to survive real users,
  2. What decisions I had to make as a founder, not a programmer: what to cut for the MVP, who to trust, what to build yourself vs. buy,
  3. When building your own passkey manager is just plain stupid, because honestly, in most cases it is.

The iOS app that came out of this is a story for a separate post. Here I’ll stay focused on the engine and the process that got me there.

What the heck is this passkey‑vault

In short: a credential manager, i.e., a relying‑party and storage implementation on the server side that handles registration and login with passkeys according to WebAuthn and FIDO2, plus a sync layer so the same user can log in from several devices without having to scan a QR code each time. Sounds like one sentence. In practice it’s twenty smaller decisions, each with security consequences if you mess up even one.

Before anyone asks: yes, functionally it looks like another Bitwarden because besides passkeys it also stores regular passwords, TOTP codes for 2FA, folders to keep the mess organized, a password generator, notes, and the ability to share a password with a trusted person without sending it over Messenger. This post focuses only on the WebAuthn layer because that was by far the hardest piece to get right, but the rest of the product exists and works. It just deserves its own post. The difference isn’t in the feature list, it’s in why it was built: not to compete in the market, but to stop paying for other people’s limitations and have a tool that is my tool. More on founder‑level decisions later.

First problem: the demo in the spec lies by omission

Every WebAuthn tutorial looks roughly like this:

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: new Uint8Array(32),
    rp: { name: "passkey-vault", id: "passkey-vault.example" },
    user: { id: userId, name: email, displayName: email },
    pubKeyCredParams: [{ type: "public-key", alg: -7 }],
  },
});

Nice, short, works on localhost in the demo. The trouble starts when rp.id must exactly match the domain the page is served from, counting the registrable domain, not an arbitrary sub‑domain. You have app.passkey-vault.com and api.passkey-vault.com, you want the credential to work on both? You have to deliberately set rp.id to the common suffix, not just copy what happened to work locally. Change the production domain after the fact? All registered credentials stop matching because they’re permanently bound to the origin. That’s not a bug to fix; it’s a protocol property the demo never tells you about because the demo never changes domains.

The second painful piece shows up only in practice: local testing requires either https://localhost or a virtual authenticator via Chrome DevTools, because browsers refuse WebAuthn on plain HTTP except for the localhost exception. My first attempt at integration tests was to mock the whole navigator.credentials. Quickly I discovered the mock missed half the real‑world errors, because a real authenticator has quirks: some hardware keys don’t support discoverable credentials, some platform implementations cache state between attempts so two consecutive CI runs see different results. I eventually switched to the virtual authenticator through the Chrome DevTools Protocol and only then did the tests start catching something real.

Second problem: what you actually store in the database

This is the moment where most people (including me at the start) get it wrong. The private key never leaves the user’s authenticator, so the server never sees it and never stores it. What actually lands in your DB is: credentialId, the public key, the signature counter (signCount), the backup eligible and backup state flags, and optionally attestation metadata. Sounds innocent until you ask yourself what’s sensitive about it.

Answer: credentialId isn’t a secret, but it’s an identifier that should be treated as personal data because it ties a specific user to a specific device. The signature counter is your only line of defense against a cloned hardware authenticator: if the counter on a subsequent login is less than or equal to the previous one, that’s a signal that someone copied the key data and the login should fail. The problem is that cloud‑synced passkeys (iCloud Keychain, Google Password Manager) often report signCount = 0 on every login because the counter makes no sense when the same credential lives on many devices simultaneously. So the “counter must increase” rule breaks exactly for the use‑case that was supposed to be the most convenient. I had to split verification into two modes: a strict one for hardware keys with a real counter, and a lax one for credentials marked as backup eligible, where a zero counter is normal, not an alarm.

Third problem: username‑less login that had to be redesigned twice

Discoverable credentials, passkeys the browser can suggest without the user typing a login, are the number‑one selling point for passkeys. And rightly so, because the UX is better. But implementing it well requires the server to be able to generate a login challenge without knowing the user’s identity, because by definition you don’t know it yet. My first login endpoint silently assumed: first find the user, then generate the options. It worked for login‑with‑email flows, but immediately broke for a “login with passkey” button that has no email field.

I had to turn it into a two‑step flow: an endpoint that generates options with no user context, plus a verification endpoint that, only after receiving the authenticator’s response, looks up the user by the credentialId returned. Easy to describe in one sentence, but exactly the kind of change where you have to rewrite server‑side validation because the silent assumption that the session already carries identity is no longer true. Here the identity arrives at the end, not at the beginning.

Where the phone comes in: sync between devices

My biggest real‑world problem wasn’t cryptographic, it was distributional: how to make a passkey registered on a laptop actually usable on a phone without re‑registering every time. The platform answer is hybrid transport: login from the phone to a browser on another device via QR and Bluetooth as a proximity‑confirmation channel (caBLE), or full sync through the OS provider (iCloud Keychain on Apple, Google Password Manager on Android).

That’s where the iOS app, credential‑provider extension, system Autofill integration, and the whole UX on the device start to matter. That’s a separate, hefty story, so I’ll expand on it in the next post. For now just remember one thing: the decision to support hybrid transport instead of forcing a fresh registration on every device was a product decision, not a technical one, and I only made it after I pissed myself on my own MVP that forced me to re‑register on every device.

Decisions I made as a founder, not as a programmer

Here’s the part no WebAuthn docs write about, because the docs don’t know you’re building a product, not a cryptography exercise.

What to build yourself vs. what to trust. I left the actual cryptography, signature verification and CBOR/COSE parsing, to a vetted library, because writing your own CBOR/COSE from scratch is exactly the kind of work where a single bug costs far more than the time you’d save. I wrote the storage logic, data model, signature‑counter policy, and the whole registration‑and‑login flow myself, because that’s where the product’s differentiating value lives, not something you outsource.

When to stop adding attestation. WebAuthn supports attestation, a cryptographic proof that a credential comes from a specific certified hardware model. Sounds like something you must verify. The catch: verifying attestation requires maintaining and updating manufacturers’ metadata (FIDO Metadata Service), which is a job in itself, and most real users log in with a platform authenticator, not a certified hardware key, so attestation usually ends up as none. I dropped strict attestation verification for the MVP and logged it as an intentional technical debt, not an oversight. That’s the difference between an MVP and negligence: one is documented and justified, the other just surprises you later.

Why build it yourself when Bitwarden and 1Password already have it. Honestly? The main reason wasn’t “I want to understand the protocol under the hood” (though that’s true too). The main reason was that I was fed up paying month after month for someone else’s tool with limits someone else invented. I used NordPass before, and the plan I could afford let me be logged in on maybe two devices at once. Two. When you have a laptop, a phone, a second phone for testing, and occasionally a tablet, that limit hurts daily, not once a year. Instead of constantly renewing a subscription and accepting someone else’s decision about how many devices I may log into, I decided to build something where I set the limit: practically none. That’s the difference between renting a tool and owning it. The fact that I now understand every line responsible for my future users’ login security is a nice side effect, not the original goal. Honestly, part of the decision is the classic “not‑invented‑here” syndrome, but part of it is a simple calculation: I’d rather pay once (occasionally) than pay every month for something that still restricts me.

When building your own passkey manager doesn’t make sense

I’m being fair, because this post could easily be read as a rallying cry for everyone to go crazy. Don’t build your own passkey manager if:

  • Passkeys are just one of many login methods for you, not the product’s foundation. In that case integrating a ready‑made provider (Clerk, Auth0, WorkOS, whatever) costs you a day, while a custom implementation costs months and exposes you to security bugs that a mature provider has already ironed out.
  • You don’t have the time budget to keep up with an evolving standard. WebAuthn and FIDO2 keep changing, new extensions like PRF are just maturing in browsers, and someone has to track that.
  • Your team has no one who actually enjoys reading cryptographic specifications for fun. Seriously. This isn’t a one‑and‑done project; it’s a long‑term commitment that needs a caretaker.

If none of those three points apply, meaning passkeys are core to what you’re building, you have time for maintenance, and someone on the team genuinely likes digging into specs, then building your own starts to make sense. All three conditions lined up for me. That doesn’t mean they’ll line up for you.

What came out of it

Passkey‑vault works: it handles registration, username‑less login, and basic sync via hybrid transport. It doesn’t yet do full attestation verification, and that’s a conscious omission, not a forgotten feature. The biggest lesson from the whole process isn’t about cryptography; it’s about how many decisions that look technical are actually product decisions in disguise: what to cut, who to trust, and who you’re building this for.

FAQ

How does a passkey differ from a regular password in terms of what the server stores?
The server never sees or stores the private key; it stays inside the user’s authenticator. On the server side you only get: the credential identifier, the public key, the signature counter, and backup flags. That’s a fundamentally different threat model than a password‑hash database, because even a full database leak gives an attacker nothing they can use to log in.

Is building my own WebAuthn backend safe if I’m not a crypto expert?
Leave the raw crypto, signature verification and CBOR parsing, to a well‑audited library. What you build yourself is the business logic around it: storage, counter policies, login flow. You can still make security mistakes there, but they’re far easier to reason about and test than writing your own crypto primitives.

Why is the signature counter sometimes zero on every login?
Because a cloud‑synced passkey used across multiple devices doesn’t have a single, ever‑increasing counter; the same credential is being used from many places at once. That’s normal for credentials marked as backup eligible and backup state. The hard rule “counter must increase” only makes sense for unsynced hardware keys.

What about the iOS app, credential provider, and Autofill integration?
That’s a separate, larger topic that deserves its own post, because iOS system Autofill brings a completely different set of challenges than a WebAuthn backend. I’ll cover it in the next article.

Is this a viable product or just a learning project?
It started as a learning project to understand the protocol. Along the way it began to look like something that could be a standalone product, mainly because the frustration that sparked it is real and affects more people than just me. Whether it actually turns into a product, we’ll see. For now it’s a solid engine that I understand from the inside out.

Summary

That’s it. The irony is that I started this because I was pissed off at other password managers that kept breaking when I tried to log in with a passkey. A few months later I have my own manager, and I can get lost in it too, but now I know exactly why, because I wrote it myself. It’s not any less frustrating. It’s just a lot more instructive, and that’s supposedly the good kind of failure.

The iOS app, credential provider, and the ten ways iPhone Autofill can surprise you are coming next time. Stay safe, buddy!

Keep exploring

No perfect tag overlap yet, so here are the freshest posts.