← Back to blog

Seamless Auth in a Summer

· Brandon Corbett · 1 views
passwordlessseamlesssecurityrelease

The last time I wrote a state-of-the-project post, Seamless Auth was a passwordless authentication server with a React SDK, an Express adapter, and a scaffolding CLI. That was accurate, and it undersold the problem. An auth server is easy to publish. An auth stack that a stranger can adopt without a week of guesswork is the actual work.

Most of what shipped since then went into that second thing.

The auth API is at v0.7.4. The server SDKs are at 0.12.1. The React SDK is at 0.8.0. There is a new Fastify adapter, a new shared types package, and a typed client generated from the OpenAPI document. Here is what all of that means in practice.

The wire contract now has one home

The single largest refactor of the cycle is invisible in a changelog diff and shows up everywhere else.

@seamless-auth/types is now the source of truth for the shapes that cross the wire. The auth API adopted it and turned 88 schema definitions across 30 files into re-exports, deleting roughly 900 lines of definitions it used to maintain by hand. The core, Express, and React packages did the same.

Before this, the API, the server adapters, and the React client each described the same response in their own words. They agreed almost all of the time. "Almost" is where the bugs live. One of them was a registration response sending its ttl as the string '300' instead of the number 300. The Express adapter multiplied it into milliseconds, which coerced the string, so it worked. The Fastify adapter handed it to a cookie library that requires an integer, so registration failed there and nowhere else.

With one package owning the contract, a change like that moves in lockstep across the stack or it does not move at all.

Two things fell out of that work:

  • A standardized error response shape. Every failure now looks the same, carries a code, and keeps its details payload instead of having it stripped by a narrower schema on the way out.
  • A real typed API client generated from OpenAPI. Not a hand-maintained wrapper that drifts from the server. Generated from the document the server actually publishes.

Fastify is a supported adapter

@seamless-auth/fastify is published and exercised by CI, alongside @seamless-auth/express.

Adding it forced a good structural change first. The passthrough proxy, the response contract, and auth message delivery had all quietly accumulated inside the Express package. They belong to neither framework, so they moved into @seamless-auth/core. The Fastify adapter is thin because core got thicker, which is the right direction: a third adapter should be a small amount of work, not a fork.

Both adapters proxy the admin console, both proxy the public system config, and both are covered by the CLI's conformance harness against a real scaffolded app.

The sign-in screen no longer has to guess

GET /system-config/public is a new unauthenticated endpoint that returns the configured login methods and nothing else.

This sounds trivial. It fixed a real category of bug. The bundled sign-in screens in the SDKs render before anyone has a session, so they could not read the instance configuration, and fell back to a hardcoded list of methods. That list could confidently advertise a login method the instance had turned off.

It also unblocked something adopters kept asking for: letting a user finish registration without registering a passkey. That is only safe when another login method is actually enabled, and a client that cannot see the method list cannot make that judgment. Now it can, and the React SDK offers the skip.

Every other configuration key stays behind the admin routes.

Admin access got a vocabulary

Admin roles are scoped. admin:read and admin:write are real, assignable, and enforced.

POST /admin/users and PATCH /admin/users/:userId now reject any role that is not in the instance's available_roles, and name the offending role in the response. Previously a typo like admin:reed was accepted, stored, granted nothing, and reported no error, which is the worst possible combination.

requireRole understands the hierarchy: a legacy broad admin grants both scopes, admin:write grants admin:read, and admin:read grants neither write access nor a plain admin check.

Related and overdue: the admin bootstrap invite flow is gone. It was a secret-in-an-env-var dance that existed only to create the first admin. Set OWNER_EMAIL and that account is granted admin on signup. Fewer moving parts, one fewer secret to leak.

The admin console ships with the server

The admin dashboard is served at /console by the auth API itself, pinned to a released version (currently v0.4.0). You do not deploy a second frontend to administer your instance.

The dashboard itself had a heavy cycle: organization management with editable memberships, an unsaved-changes guard on system configuration, corrected monitoring figures with refresh and export, a step-up authentication path for admins who have no passkey, and a full accessibility pass over the app shell, tables, and charts so the thing is operable by keyboard and legible to a screen reader.

Operational fixes that matter more than features

These are the changes I would want to read about if I were evaluating this project.

TLS to Postgres. DB_SSL accepts true/false or an sslmode value, DB_SSL_CA supplies a CA bundle as inline PEM or a file path, and verification follows libpq semantics. DB_URI is accepted as an alias for DATABASE_URL. Connection and TLS resolution is shared between the running app and the startup migrations, so a connection string configured without the discrete DB_* variables no longer breaks migrations at boot.

Correct rate limiting behind a proxy. Express leaves trust proxy off by default, so behind a load balancer req.ip resolved to the balancer. Every client shared one bucket in the global rate limiter. TRUST_PROXY sets the number of proxies in front of the server. It stays unset by default, because a directly reachable server that trusts the header lets a client forge its own address.

Sessions that survive a quiet afternoon. The default refresh_token_ttl was one hour, which capped the whole session at one hour no matter how active the user was. An app holding state locally without making API calls (a long form, say) could not refresh afterward, and the first save returned 401 and lost the work. The default is now one day, matching the session row's real lifetime.

OAuth hardening. Providers are manageable at runtime through admin routes. Callback failures return actionable codes instead of a generic error, and the React SDK surfaces them in sign-in messaging. The Express adapter added a cross-site request guard for SameSite=None cookies. Provider access tokens are still never stored by the adapter, returned to the frontend, or placed in a cookie, and provider client secrets stay on the auth API host.

The API's unit and integration suites sit at roughly 99% coverage.

Getting started is one command, and it knows about managed

The CLI is now seamless-cli (it used to be create-seamless), at v0.11.0.

npx seamless-cli init my-app

What changed:

  • seamless login plus seamless init connects to a managed instance. If you are signed in to the portal and have a provisioned application, init offers to wire the new project to it instead of scaffolding a local auth server. It issues the real service token from the control plane and writes the managed auth server URL and JWKS key id into api/.env. Database user and password are written as literal placeholders on purpose: the CLI never asks the control plane to reveal them, so no live database credential lands on your disk.
  • A non-interactive init for scripts and CI, with template flags validated up front, and a guard that fails fast when a command that prompts has no terminal to prompt on.
  • Per-command help. seamless verify --help documents verify only, not a wall of everything.
  • Starters for Express and Fastify APIs, plus React Vite and React OAuth web templates, all scaffolding on PostgreSQL 18, and a session inspector page so you can see what the cookie actually contains.
  • A conformance harness (seamless verify) that boots the scaffolded stack and exercises the real flows, now including the Fastify starter.

Documentation lives at docs.seamlessauth.com and is organized around what you are trying to do: start, build, operate, understand. The first thing it asks you to decide is managed or self-hosted, because that is the only decision that changes your integration, and the answer is mostly a configuration difference rather than a rewrite.

Where this leaves the project

Self-hosted Seamless Auth is genuinely usable today. You can scaffold a stack, run it on Docker, administer it from a console the server ships, connect an Express or Fastify API, and put a React frontend on top of it, with passkeys, magic links, OTP, TOTP, OAuth providers, organizations, and scoped admin roles.

What is honestly still rough: production deployment is your responsibility, since the CLI has no deploy command. The managed path handles that, and its free trial tier is still in progress rather than live.

Passwordless is not the hard part anymore. Making it adoptable is. That is the part getting the attention.

© 2026 Seamless Auth