Four bugs our test suite said were good, but users proved wrong
I maintain Seamless Auth, an open source passwordless authentication server. I also build products that run on it: a review marketplace and, as of this week, a word game.
That second part turned out to be the important one. Over the last month those two products found four bugs in the auth stack that the test suites did not, and could not, have caught. All four had passing tests. None of them were logic errors. Every one of them lived in a seam between two things that were each individually correct.
Here they are, in the order they hurt.
1. The screen with no exit
Registration ended on a passkey enrollment screen with exactly one control on it: create a passkey.
If you did not want a passkey, or your device could not make one, you were finished. The unsupported branch rendered an explanatory message and nothing else, on a screen with no way forward and no way back.
The detail that makes this embarrassing is that the session already existed. The OTP step that leads to enrollment establishes it. Leaving without a passkey was always a legitimate way to finish registering. The server was fine. The user was authenticated. The screen just did not offer the door.
The fix was not simply adding a skip button, because a skip is only safe when the user has another way back in. If passkey is the only enabled login method, letting someone skip enrollment strands them out of the account they just made. So the client now reads the instance's configured login methods and renders the skip only when a method other than passkey is enabled. A failed or in-flight read shows no skip at all: unknown counts as unsafe rather than guessing.
Which required solving a different problem first.
2. The sign-in screen that advertised a method the server had turned off
The bundled sign-in screens render before anyone has a session. That means they cannot read the instance configuration, because reading it required authentication. So they fell back to a hardcoded list: passkey, magic link, phone OTP.
If an instance had turned one of those off, the sign-in screen offered it anyway. The client was confidently advertising capabilities the server would refuse.
The fix is a new unauthenticated endpoint, GET /system-config/public, that returns the configured login methods and nothing else. Every other key stays behind the admin routes.
The interesting part is the failure mode. The handler reads through the login policy resolver, so a tainted or partially written configuration answers with defaults instead of erroring. A signed-out client with no methods has nothing to render, and a 500 there would take the entire sign-in screen down with it. When the thing you are protecting is the front door, failing closed means nobody gets in, including the people who should.
3. The string that was a number everywhere else
The registration response sent its ttl as the string '300'. It was the only ttl in the entire API that was not a number.
A caller sets its registration cookie from that value. The Express adapter multiplies it into milliseconds, and JavaScript quietly coerces the string, so it worked. The Fastify adapter passes it straight to a cookie library that requires an integer, so it did not:
TypeError: option maxAge is invalid: 300
Same response. Same contract. One adapter worked by accident, and the accident hid the bug for as long as nobody used the other one.
The fix moved the schema, not just the value. RegistrationSuccessSchema.ttl became a number in the shared types package, and response bodies are validated against that schema at runtime, so the server and the contract have to move together. You cannot regress it now without failing validation.
This is the argument for a shared contract package in one paragraph. I had been maintaining schema definitions in the API, both SDKs, the dashboard, and the CLI, in parallel, by hand. Consolidating them turned 88 definitions across 30 files into re-exports and deleted roughly 900 lines. It also surfaced types that had been lying: Credential.lastUsedAt was declared Date | null, but the API serializes it as an ISO 8601 string. Any code that called a Date method on it was trusting a type that never matched the wire value, and threw at runtime when it ran.
A type that does not match the wire is worse than no type. It tells you to stop checking.
4. The session store that died on remount
This is the one that cost me actual user work, and it is the most React-specific of the four.
I moved session state into a framework-agnostic store behind the provider, read through useSyncExternalStore. Clean design. The provider tore the store down in its effect cleanup, which is the textbook thing to do.
React does not guarantee that cleanup means unmount. It can run mount, cleanup, mount against the same provider. StrictMode does exactly this on every mount in development. Activity does it in production whenever a hidden tree is shown again.
The store's destroy() is terminal. So the second mount got a store that refused every update and sat on loading: true forever. The user was signed in. The provider would never say so.
The fix is to stop destroying it there. Nothing leaks: useSyncExternalStore removes its own listener when the provider unmounts, and the store owns no timers or subscriptions, so it is reclaimed with the component.
While I was in there I found a smaller one worth mentioning. When the session check failed, the SDK was firing a DELETE /logout for a session the server had already declared unusable. Every anonymous page load made a pointless second request. It now clears locally.
What these have in common
Every one of these had passing tests, because a test suite asserts that a unit does what you meant it to do, and none of these units misbehaved. The system did.
- The dead end was a seam between a user's intent and the screen's options.
- The advertised method was a seam between a default and a configuration.
- The string
ttlwas a seam between two adapters reading the same contract differently. - The dead store was a seam between my code and React's actual lifecycle.
You do not find seams by testing units harder. You find them by running the whole thing, in front of a person who is trying to accomplish something, and watching where they stop.
I had been treating my own products as showcases for the auth server. They are more useful than that. They are the only integration test I have that includes a human being.
What changed as a result
Beyond the four fixes, a few things I tightened in the same round:
POST /loginnow answers every failure with one identical401 { "error": "Not Allowed" }. An unknown identifier, an unverified account, and an account with no permitted continuation method used to produce three distinguishable bodies, which let an unauthenticated caller tell them apart. Operators keep the detail in the auth event metadata.- Scoped admin roles are validated on assignment. A typo like
admin:reedused to be accepted, stored, grant nothing, and report no error.
All of it is in the open source core. If you want to read the code, it lives at github.com/fells-code/seamless-auth-api, and the SDKs are alongside it.
A long list of fixes is not a reason to distrust a project. A short one, on software nobody has run yet, is.