Security Posture and Threat Model
This page is written for a security reviewer. It states what the Seamless Auth API
(seamless-auth-api) does to protect authentication, which risks it accepts on purpose, and
where its responsibility ends. Every claim links to the code or configuration at the
v0.19.1 release tag, so it
can be checked rather than taken on trust.
The longer rationale for each accepted tradeoff lives in the API repository’s
docs/security-posture.md
and operational guidance in
docs/production-operations.md.
Trust Boundaries
Section titled “Trust Boundaries”The API is a bearer/JSON service. It sets and reads no browser cookies (there is no cookie
handling anywhere in src/).
The intended topology is:
browser -> @seamless-auth/react (cookies) -> server adapter (@seamless-auth/server, @seamless-auth/express) -> auth API (Bearer/JSON)| Party | What the API trusts it with |
|---|---|
| Server adapter | Holding tokens on the browser’s behalf, setting cookies, and CSRF protection. Cookie and CSRF behaviour belongs to seamless-auth-server, not to this API. |
| Service token holders | A holder of SEAMLESS_SERVICE_TOKEN can mint internal service tokens, choose the client IP and user agent recorded for a request (trustedClientContext.ts), and use external delivery mode, which returns OTP codes and magic links to the caller instead of sending them. Treat it as an admin secret. |
| Postgres | All durable state. A role that owns the tables can rewrite them, including the audit trail (see Audit Logging). |
| Whoever runs the image | Environment variables, signing keys and the choice of NODE_ENV. |
Internal service tokens are HMAC JWTs (HS256, HS384 or HS512) and are refused when they carry
no exp or an exp more than an hour and a minute away
(authenticateServiceToken.ts).
Threat Model Summary
Section titled “Threat Model Summary”What it defends against
Section titled “What it defends against”| Threat | Control |
|---|---|
| Phishing of passkey credentials | WebAuthn verification checks the expected origin and RP ID (webauthn.ts). |
| WebAuthn assertion replay | Challenges are stored server side with a 300 second TTL and a per-flow purpose, and consumed on read before verification (webauthnChallengeService.ts). |
| Guessing OTP and TOTP codes | Per-account lockout on failed codes, plus per-IP and per-identity flow rate limits (see Authentication Factors and Availability and Scaling). |
Account enumeration on /login |
An unknown identifier gets a signed decoy pre-auth token, and every endpoint that accepts a pre-auth token answers a decoy the way it answers a real account. defineRoute refuses to register an auth: 'ephemeral' route without a decoy responder (defineRoute.ts), and /login holds every pre-auth answer, real or decoy, to a minimum time, LOGIN_RESPONSE_FLOOR_MS (default 250 ms) (authentication.ts). |
| Stolen refresh tokens | Single-use rotation with reuse detection that revokes the session chain (see Session Management). |
| Token confusion | Every JWT is verified for RS256, issuer, audience and typ, so an access token is not accepted where a pre-auth token is expected, or the reverse (sessionService.ts). |
| OAuth account takeover by unverified email | Linking to or creating an account from an OAuth profile requires the provider to assert the email is verified (oauthService.ts). OAuth state is HMAC signed, expires after ten minutes and is refused on reuse, and PKCE is on unless a provider sets pkce: false. From 0.19.2 the state is also bound to the client that started the sign-in (#463); see OAuth Client and RFC 9700 for every client recommendation. |
| Requests from disallowed browser origins | A request whose Origin is not allowlisted is refused with 403 before the route runs, and recorded as request_suspicious (app.ts). |
| Tampering with the audit trail | Hash-chained, append-only auth_events enforced by database triggers (see Audit Logging). |
| Running a development build in public | The server refuses to start with NODE_ENV other than production when ISSUER or APP_ORIGINS names a non-local host, unless SEAMLESS_ALLOW_INSECURE_DEV=true is set on purpose (devModeGuard.ts). Development mode is what enables on-disk signing keys, guessable fallback secrets, /docs, and the switch that disables rate limits. |
| Secrets in logs and API responses | Log lines and auth event metadata are redacted, and control characters in log lines are escaped (redaction.ts, logger.ts). |
What it does not defend against
Section titled “What it does not defend against”- A compromised server adapter or service token. Either can act for any user the adapter serves, and the service token can read OTP codes and magic links through external delivery mode.
- A compromised database owner role. It can disable the audit triggers and rewrite the chain consistently. Least privilege and external anchors are the mitigation, and both are the operator’s to set up.
- A compromised host or environment. Signing keys and secrets arrive as environment variables. Anyone who can read the process environment can sign tokens.
- Browser-side attacks on cookies. Cookie flags, CSRF and session cookie lifecycle belong to the server adapter.
- A compromised email inbox or phone number. OTP and magic link factors prove possession of an address. Passkeys are the factor that does not depend on one.
- Volumetric denial of service. Rate limits are per process and in memory. Put a proxy or load balancer limit in front for anything beyond request-level abuse.
Accepted risks
Section titled “Accepted risks”These are deliberate, stated in
docs/security-posture.md,
and not open bugs.
| Risk | Why it is accepted |
|---|---|
| Lockout discloses that an account exists | Only a real account can be locked. A locked account with no passkey answers /login with 423 and retryAfterSeconds; one with a passkey continues with loginMethods: ["passkey"]. It needs prior failed attempts against that account, and hiding it would leave a locked-out user with no explanation (lockoutPolicyService.ts). |
| A pre-auth token proves only possession of an address | /login and /registration/register mint a pre-auth (ephemeral) token from an identifier alone. It can only continue a flow that still needs an OTP code, a magic link or a WebAuthn assertion. Anything that changes how an account signs in, such as passkey enrollment, requires an access session (webauthn.routes.ts). |
| Pre-auth tokens can be replayed until they expire | They are signed with a five minute expiry and are not single use (token.ts). The continuation endpoints are rate limited, and the credential each one needs is single use. |
| Audit writes are best effort | A failed write to auth_events is logged and swallowed rather than failing the request (authEventService.ts). No security control reads the audit trail, and /health/status reports degraded for five minutes after a failure (auditHealth.ts). A deployment cannot make authentication fail closed on audit failure today. |
| Email and phone are stored in plaintext | They are lookup keys for sign-in and admin search (users.ts). Use database storage encryption to protect them at rest. |
| External delivery mode distinguishes a decoy | A real account’s OTP or magic link answer carries a delivery block and a decoy’s does not. The mode requires a valid internal service token, whose holder can already list users through the admin API. |
| Per-instance limits | Rate limit counters, the system_config cache, the OAuth state replay cache and the audit degraded flag are held per process. See Availability and Scaling. |
| Development mode can be forced on a public host | SEAMLESS_ALLOW_INSECURE_DEV=true overrides the guard and logs a warning at boot. It exists for deliberate test deployments and should never be set in production. |
| Metadata outage degrades attestation checks | Under attestation: 'direct', when the FIDO Metadata Service blob cannot be fetched, registration continues without metadata validation rather than failing, so a model the service does not list is not refused even with requireKnownAuthenticator. The fetch is retried on later registrations, and credentials.attestationVerified stays false meanwhile (metadataServiceBootstrap.ts). |
Authentication Factors
Section titled “Authentication Factors”| Factor | How it is protected |
|---|---|
| Passkeys (WebAuthn) | Origin and RP ID are verified. User verification defaults to required and attestation to none (authenticator_policy, schema in @seamless-auth/types). Supported algorithms are EdDSA, ES256 and RS256 (webauthnAlgorithms.ts). Synced passkeys are allowed by default and can be blocked with syncedPasskeys: 'block'; AAGUID allow and deny lists and requireKnownAuthenticator apply under attestation: 'direct' (authenticatorPolicyService.ts). |
| Email and SMS OTP | Email codes are six random uppercase letters and SMS codes six random digits, both drawn from crypto.randomInt, valid for five minutes, stored as SHA-256 hashes and compared in constant time (otp.ts). |
| Magic links | 32 random bytes, stored as a SHA-256 hash, valid for 15 minutes, consumed once by a conditional update, and bound to the sign-in attempt that requested them. A link opened on a different device than the one that asked for it needs an explicit confirmation step (magicLinks.ts). |
| TOTP | Secrets are encrypted at rest with AES-256-GCM under a key derived from TOTP_SECRET_ENCRYPTION_KEY, and the last used counter is stored so a code cannot be reused (totpService.ts). |
| OAuth | Covered under What it defends against. |
| Step-up | Routes marked with requireStepUp need a step-up verification on the current session within the last five minutes by default (stepUpService.ts). Audit export is one of them. |
Account lockout
Section titled “Account lockout”The policy is lockout_policy, default
{ "enabled": true, "maxFailures": 10, "windowSeconds": 900, "lockoutSeconds": 900 }
(systemConfig.defaults.ts).
- An account locks when its last
maxFailurescounted failures fall withinwindowSeconds, and stays locked forlockoutSecondsafter the most recent of them (lockoutPolicyService.ts). - Counted failures are failed logins, OTP codes, TOTP codes (sign-in and step-up) and magic links
(
authFailureCounter.ts). Failed passkey assertions are not counted. - Lockout blocks the code factors only. Passkey sign-in stays available, so anyone who knows an address cannot lock its owner out of their passkey (GHSA-66gq-4pwg-33jq).
- Failures are stored in the
auth_failurestable, separate from the audit trail, so every instance sees the same count. If the count cannot be read, the account is treated as locked.
Session Management
Section titled “Session Management”More detail on session records is in the Session Model.
| Property | Behaviour |
|---|---|
| Refresh token format | 32 random bytes, opaque, not a JWT (token.ts). |
| Refresh token at rest | Only an HMAC-SHA256 fingerprint keyed with REFRESH_TOKEN_LOOKUP_SECRET is stored, and it is compared in constant time on lookup (sessionService.ts). |
| Rotation | Every refresh issues a new refresh token and links the old session to the new one. The link is written by a single conditional statement, so of two concurrent refreshes of the same token only one can win; the loser’s new session is revoked with rotation_race_lost (authentication.ts). |
| Reuse detection | Presenting an already rotated refresh token revokes the session chain from that point forward, records refresh_token_suspicious, and answers 401 refresh_token_reused. Two legitimate concurrent refreshes of one token therefore sign the user out. |
| Absolute lifetime | Rotation does not extend a session. Every session in a chain carries chainStartedAt, and its expiresAt is chainStartedAt plus refresh_token_ttl. Refusals are recorded with absolute_lifetime_reached. |
| Idle lifetime | A session that is not refreshed within session_idle_ttl (default 8h) expires. Refusals are recorded with idle_timeout. |
| Access token checks | Access tokens are RS256 JWTs carrying the session id. The API checks on every request that the session is not revoked, not rotated and not expired, and that the user is not revoked (sessionService.ts). A relying party that verifies the JWT locally against JWKS cannot see a revocation until the token expires, so keep access_token_ttl short. |
| Concurrent session cap | max_concurrent_sessions is unlimited (null) by default. When set, a new sign-in at the limit succeeds and the user’s oldest sessions are revoked with concurrent_session_limit, recorded as session_evicted (concurrentSessionPolicy.ts). A revocation that fails is logged and the sign-in continues. |
| Revocation | Users revoke their own sessions through DELETE /sessions/:id and DELETE /sessions (session.routes.ts), and admins through DELETE /admin/sessions/by-id/:id and DELETE /admin/sessions/:userId/revoke-all (admin.routes.ts). Revocation takes effect on the next request the API sees. |
access_token_ttl and refresh_token_ttl have no built-in default. The server refuses to boot
until ACCESS_TOKEN_TTL and REFRESH_TOKEN_TTL are set or the values are already in
system_config
(bootstrapSystemConfig.ts).
The shipped .env.example
uses 30m and 1d. See System Config for every key.
Cryptography and Keys
Section titled “Cryptography and Keys”| Item | Algorithm and handling |
|---|---|
| Access and pre-auth tokens | RS256 JWTs with iss and aud equal to ISSUER, typ of access or ephemeral, and a kid header (token.ts). |
| Refresh token fingerprints | HMAC-SHA256 keyed with REFRESH_TOKEN_LOOKUP_SECRET. |
| OTP codes and magic links | SHA-256 hashes. |
| TOTP secrets | AES-256-GCM, key derived by SHA-256 from TOTP_SECRET_ENCRYPTION_KEY. |
| OAuth state and PKCE | HMAC-SHA256 keyed with OAUTH_STATE_SECRET. |
| Decoy subjects | HMAC-SHA256 keyed with DECOY_SUBJECT_SECRET (decoyPrincipal.ts). |
| Internal service tokens | HMAC JWTs keyed with SEAMLESS_SERVICE_TOKEN. |
JWKS signing keys
Section titled “JWKS signing keys”In production the server reads its keys from environment variables
(signingKeyStore.ts):
SEAMLESS_JWKS_PUBLIC_KEYSlists every published public key./.well-known/jwks.jsonpublishes all of them, and verification picks the key by the token’skid.SEAMLESS_JWKS_ACTIVE_KIDselects the signing key, andSEAMLESS_JWKS_KEY_<kid>_PRIVATEholds its private half.- Keys are re-read every five minutes within a process. The server never writes or rotates keys itself.
Rotation is three deploys: add the new public key, switch SEAMLESS_JWKS_ACTIVE_KID to it, then
remove the old key once access_token_ttl has passed. Access tokens are the longest-lived signed
tokens, and refresh tokens are not signed, so rotation does not affect them.
Outside production the server generates an RSA key pair on disk under keys/dev and signs with
it. The development mode guard keeps that from running on a public
host by accident.
Secrets that fall back to the service token
Section titled “Secrets that fall back to the service token”REFRESH_TOKEN_LOOKUP_SECRET, TOTP_SECRET_ENCRYPTION_KEY, OAUTH_STATE_SECRET and
DECOY_SUBJECT_SECRET each fall back to the service token when unset, and the server logs a
warning at boot naming each one that does
(serviceToken.ts).
While they share it, rotating the service token signs every user out and makes every stored TOTP
secret unreadable.
To separate them: set each of the four to the current service token value and deploy, then rotate the service token on the API and every server adapter. Do not set them to new values in the first step, because stored refresh fingerprints and TOTP secrets were made with the old value. The fallback is deprecated and the boot warning states it is removed in v0.20.
SEAMLESS_SERVICE_TOKEN and the older API_SERVICE_TOKEN are both accepted. The server refuses
to start if both are set to different values.
Outside production, unset secrets fall back to fixed development values. In production, the server throws instead of using them.
Audit Logging
Section titled “Audit Logging”Sign-in outcomes, factor verifications, session lifecycle, admin actions and refused requests are
written to auth_events. See Auth Events and Auditing for the event model.
Integrity
Section titled “Integrity”The protections are installed by the migration
20261008130000-protect-auth-events.cjs:
- Hash chain. An insert trigger gives each row a
seqandhash = sha256(prev_hash || payload). The chain head is a one-row table advanced with anUPDATE, so concurrent inserts serialise on its row lock. - Append-only. Triggers refuse every
UPDATEandTRUNCATE, and everyDELETEunless the transaction has setseamless.audit_retention = 'on', which only the retention job does. - No user foreign key. Deleting a user no longer rewrites that user’s audit rows.
Integrity check
Section titled “Integrity check”GET /admin/auth-events/integrity (admin read) recomputes every hash and reports the first
hash_mismatch, sequence_gap, broken_link or head_mismatch, together with the current head
and the anchor hash of the oldest remaining row
(auditIntegrity.ts,
admin.routes.ts).
Export
Section titled “Export”GET /admin/auth-events/export?from=&to= (admin read, plus a fresh step-up) streams a contiguous
seq range as JSON lines, each with its prevHash, hash and the exact payload that was hashed,
followed by a manifest line. The file verifies on its own without the database. The export is
itself recorded as an auth event
(auditExport.ts).
Retention
Section titled “Retention”AUDIT_RETENTION_DAYS unset means events are kept forever. When it is set together with
AUDIT_ARCHIVE_DIR, a daily job archives expired events to that directory and only then deletes
them. It only removes a contiguous run from the start of the chain and never the newest event, so
what remains still verifies. With AUDIT_RETENTION_DAYS set and no archive directory, nothing is
deleted
(auditRetention.ts).
The auth_failures table used by lockout is not pruned by this job.
Write failures
Section titled “Write failures”Audit writes are best effort (see Accepted risks). Lockout reads
auth_failures, not the audit trail, so a failed audit write cannot disable it
(GHSA-2r8w-h4h9-jw8p).
Availability and Scaling
Section titled “Availability and Scaling”Rate limiting is layered
(rateLimit.ts,
slowDown.ts):
- a global per-client limit (
rate_limit, 50 requests a minute when unset) and a progressive slow-down afterdelay_afterrequests (25 when unset), - per-flow limits for OTP, magic link, registration and OAuth, keyed by identity where one is known
and by client address otherwise (
flow_rate_limits).
DISABLE_AUTH_RATE_LIMITS=true turns these limiters off for local testing and is ignored under
NODE_ENV=production
(rateLimitsDisabled.ts).
Behind a proxy, set TRUST_PROXY so the client address is the real one. A server adapter can pass
the browser’s address in x-seamless-client-ip, which is honoured only with a valid internal
service token.
Per-instance limits
Section titled “Per-instance limits”Sessions, refresh rotation, the lockout counter, OTP codes, magic links, WebAuthn challenges,
system_config and the audit trail live in Postgres and are shared by every instance. These are
held per process:
| State | With more than one instance |
|---|---|
| Rate limit and slow-down counters | In-memory stores. Each instance counts on its own, so a client spread across N instances gets up to N times the budget. Route by client address or enforce a limit at the proxy as well. |
system_config cache |
Five minutes per process (getSystemConfig.ts). A config write clears it only on the instance that served the write. Restart the instances when a change, such as a lockout policy or token lifetime, must apply at once. |
OAuth state replay cache |
Consumed states are remembered for ten minutes by the instance that consumed them (oauthService.ts). Another instance could accept a replayed callback in that window. The state is still signed and expiring, and the provider’s authorization code is single use. |
| Audit degraded flag | /health/status reports degraded only on the instance whose write failed. Monitor every instance. |
Production caveats
Section titled “Production caveats”- Set
NODE_ENV=production. Without it the development defaults above apply, and the server will not start on a publicISSUERorAPP_ORIGINSunless forced. - Set every secret explicitly rather than relying on the service token fallback.
- Use HTTPS end to end, and restrict
APP_ORIGINS,originsandrpidto exact values. - Back up Postgres and test restores.
- Grant the application database role no
UPDATE,DELETEorTRUNCATEonauth_events.
Vulnerability Disclosure
Section titled “Vulnerability Disclosure”Report a vulnerability privately, not in a public issue:
- through GitHub private vulnerability reporting, by opening a new report on the API repository, or
- by email to security@fellscode.com with the subject
Security Issue: seamless-auth-api.
The project has a single maintainer. It aims to acknowledge a report within three business days
and to fix critical vulnerabilities within 30 days, and discloses fixes as GitHub security
advisories once a fixed release is available. Cookie and CSRF issues usually belong to
seamless-auth-server; reporting them to the
API is fine, and they will be moved.
Supported versions
Section titled “Supported versions”Seamless Auth API is pre-1.0. Security fixes land on main and ship in the next release of the
latest minor line. Older minor lines receive no backports, so the fix for a vulnerability is to
upgrade.
| Version | Supported |
|---|---|
| 0.19.x (latest) | Yes |
| 0.18.x and older | No |
Under 0.x a minor release can contain breaking changes. Read the changelog before upgrading across a minor version.
Published advisories
Section titled “Published advisories”| Advisory | Severity | Summary | Affected | Fixed in |
|---|---|---|---|---|
| GHSA-9jg2-jjqq-3346 | High | Magic link sign-in issued a session to the requesting device repeatedly once clicked | 0.18.0 and earlier | 0.19.0 |
| GHSA-66gq-4pwg-33jq | Medium | Anyone who knew an account’s email could lock it out of every method, passkeys included | 0.18.0 and earlier | 0.19.0 |
| GHSA-p3fh-cq88-92h6 | Critical | OAuth sign-in linked to existing accounts on an unverified provider email | 0.15.1 and earlier | 0.16.0 |
| GHSA-2r8w-h4h9-jw8p | High | An audit write failure silently disabled account lockout | 0.7.4 and earlier | 0.8.0 |
| GHSA-7f64-r335-j842 | Medium | The WebAuthn challenge was not consumed on the authentication path | 0.7.4 and earlier | 0.8.0 |
The current list is always on the repository’s security advisories page.
Supply Chain and Image Verification
Section titled “Supply Chain and Image Verification”Release images at ghcr.io/fells-code/seamless-auth-api are built by
docker-publish.yml,
which release.yml
calls after a release. The workflow:
- scans the image with Trivy and fails the release on a fixable
HIGHorCRITICALfinding, - builds
linux/amd64andlinux/arm64with an SPDX SBOM and a SLSA provenance statement (sbom: true,provenance: mode=max) attached to the image index, - signs the index digest with cosign keyless signing, so there is no long-lived signing key.
Verify the signature before you deploy, then deploy by digest:
cosign verify ghcr.io/fells-code/seamless-auth-api:v0.19.1 \ --certificate-identity-regexp '^https://github\.com/fells-code/seamless-auth-api/\.github/workflows/docker-publish\.yml@refs/(heads/main|tags/v.*)$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.comA passing check shows the image was signed by docker-publish.yml in fells-code/seamless-auth-api
on GitHub Actions and recorded in the public Rekor transparency log. Do not loosen the identity to
the whole repository. The SBOM and provenance are BuildKit attestations inside the signed index;
read them by the verified digest with docker buildx imagetools inspect.
Source code checks run in CI:
- CodeQL with the
security-extendedquery suite on every pull request, every push tomainand weekly, with a gate that fails while any alert is open (codeql.yml). npm audit --omit=dev --audit-level=highon production dependencies (ci.yml).