Skip to content

Threat model for OAuth-server.ts

An authorization server is the front door of every application behind it, so the question “what is this defended against, and how do you know” deserves a written answer rather than a list of RFC numbers. This page gives that answer. It follows the structure of the OAuth 2.0 Security Best Current Practice (RFC 9700) and the older threat catalogue (RFC 6819): name the attackers, name what they want, and for each threat point at the control and at the test that would fail if the control were removed.

Every control below links to its source in the repository. Where a test holds the control, the test is named. Where a control does not exist, the Known limitations section says so, because a threat model that lists only the good news is not one you can plan around.

In scope is everything in the repository: the protocol endpoints, the end-user screens, the administration console and its API, the MCP control plane, the storage adapters and the container image. Out of scope is what the security policy excludes: denial of service by volume, findings that presuppose a compromised administrator account or a compromised database, and reports against dependencies with no demonstrated effect on this server.

What an attacker would want, roughly in order of blast radius.

Asset Where it lives Why it matters
Signing keys bucketKeys, one area for every issuer (the root’s under owner #root) Whoever holds a private key mints tokens every relying party trusts
The instance keyring KEYRING or the keyring file, supplied by the operator and never stored in the database Together with a copy of the database, it opens every sealed secret listed below
Client secrets, registration access tokens the Client collection A stolen confidential-client secret impersonates that client to the token endpoint
Refresh tokens, authorization codes, access tokens token collections, all with TTL indexes Direct access to a user’s resources; a refresh token for as long as the grant lives
End-user and administrator credentials argon2id hashes on the user record; TOTP secrets Account takeover, and for administrators, control of the instance
Sessions and interaction state the _session, _interaction and admin cookies, opaque ids into the store Riding an authenticated session without the credential
DPoP nonce secret, pairwise subject salt singleton secret documents Forging nonces; correlating users across relying parties
Configuration and the audit trail configStore, adminAudit Turning off a control silently; rewriting the record of who did what
SMTP credentials, upstream provider client secrets and signing keys settings and federation documents Sending mail as the instance; impersonating it to an upstream identity provider
SCIM credentials: static scimst_ tokens and connection client secrets provisioningConnections, both as SHA-256 digests Whoever holds one creates, changes, deactivates and deletes a project’s end users
Provisioning connections provisioningConnections, one per bound upstream provider A connection decides which upstream sign-in is which provisioned account, and whether its directory may deprovision at all
Internet --TLS--> reverse proxy --> OAuth-server.ts --> MongoDB or PostgreSQL
(terminates TLS, |
sets X-Forwarded-For) +--> upstream identity providers (OIDC)
+--> SMTP relay
+--> Sentry (optional, allow-listed event shape)
AI agent --bearer token, aud = MCP -----> |
SCIM directory --scim token per project-> | (Entra ID, Okta: /scim/v2)
upstream IdP --signed JWT, per project--> | (global token revocation,
back-channel logout)

The server assumes TLS is terminated in front of it and that the issuer is an https:// URL. It believes X-Forwarded-For only on a connection from a proxy clientAddress.source trusts, and reads it from the right, so the entries a client wrote are never the caller (caller_address.ts); otherwise the connection’s own address is the caller. Two kinds of inbound caller are somebody else’s machines rather than browsers or clients: an enterprise directory writing a project’s users over SCIM, and an upstream identity provider asking for a user’s sessions to end. Each is admitted per project, one connection or provider at a time, by an administrator, and authenticated with a credential the server checks itself; neither is trusted because of where it connects from. The database is inside the boundary: a party who can read it can read accounts, tokens and the audit trail, which is why a compromised database is out of scope and why the operator section below is not optional. The secrets the server must read back are the exception. They are sealed with a keyring that the operator keeps outside the database, so reading the database alone does not reveal them (Stored secrets).

Following RFC 9700 §3, the server is built against:

  • A web attacker who controls their own domain and can get a user’s browser to visit it. They can register a client, run a relying party, and send any request a browser can be made to send.
  • A network attacker between the user and the proxy, or between the proxy and an upstream provider. TLS is assumed to hold; what the model covers is what remains when it does.
  • A malicious or compromised client, including one legitimately registered by an attacker, trying to obtain tokens for another client or another user.
  • A malicious or compromised resource server replaying tokens it was shown.
  • An AI agent acting on the MCP control plane with a token meant for something else, or a prompt-injected agent asked to do something destructive with the permissions it legitimately has.

Not modelled: malware in the user’s own browser or device, a compromised administrator account, a compromised database, and attackers with volume alone.

Each row is a threat, the control, and the evidence. Tests live under test/ and run on every push.

Threat Control Evidence
Authorization code interception (RFC 9700 §4.5) PKCE is required of every public client, and of every confidential one unless an operator turns off pkce.required; only S256 is accepted; plain is refused pkce.ts, test/pkce/pkce.spec.ts
Redirect to an attacker-controlled URI (§4.1) Redirect URIs match by exact string; the only exception is the loopback port of a native client per RFC 8252 §7.3. No wildcards exist. Fragments, non-loopback http for native clients and non-reverse-DNS custom schemes are refused at registration checks.ts, validateRedirectUri.ts, test/helpers/redirect_uri.spec.ts
Mix-up attacks (§4.4) Every authorization response carries iss (RFC 9207); the OIDC callback of the admin console checks it authorization/, login.ts
Code replay A code is consumed on first use; a second presentation revokes the whole grant, not only the code authorization_code.ts, test/authorization_code/code.grant.spec.ts
Code bound to the wrong party The exchange checks client, redirect URI, account and grant expiry; codes live sixty seconds liveTime.ts
Cross-site request forgery on the interaction screens Interaction and session cookies are HttpOnly; Secure; SameSite=Lax. Lax withholds them on exactly the cross-site POSTs and subresource requests a forgery is made of, while still permitting the top-level navigation every sign-in that begins at a relying party depends on; the device-code and end-session confirmations also carry a per-session token param_list.ts, test/sessions/sessions.spec.ts, test/end_session/end_session.spec.ts
Open redirect through an error response (§4.11.2) For a client whose redirect URIs no operator vouched for, one created by dynamic registration or described by a metadata document, an authorization error is shown on this server’s page naming the destination host rather than redirected; administrator-created clients and successful responses are unchanged authorization_error_delivery.ts, provenance.ts, test/untrusted_redirect/untrusted_redirect.spec.ts
Open redirect after logout post_logout_redirect_uri must be registered on the client or the parameter is refused end_session.ts
Clickjacking of the consent screen Every HTML page ships frame-ancestors 'none' and X-Frame-Options: DENY; inline scripts are allowed by hash only, never by 'unsafe-inline' csp.ts, test/csp/csp.spec.ts
Threat Control Evidence
Refresh token theft and replay (§4.14) Public clients and any token past seventy percent of its life are rotated; presenting a consumed refresh token destroys it and revokes the grant family; total lifetime is capped refresh_token.ts, tokens.ts, test/refresh/
Access token replay by a resource server (§4.10) DPoP (RFC 9449): htm, htu, ath, jti replay detection and server nonces derived from a rolling secret; a code exchanged with dpop_jkt yields tokens bound to that key, and the binding is re-checked on refresh validate_dpop.ts, dpop_nonces.ts, test/dpop/dpop.spec.ts
Token presented to the wrong audience (§4.9) Resource indicators (RFC 8707) restrict the audience; the MCP plane refuses any token whose aud is not itself, and UserInfo refuses audience-carrying tokens check_resource.ts, principal.ts, test/resource_indicators/
One tenant’s token accepted at another tenant’s resource server Each project with an address of its own signs with keys of its own and publishes them at its own jwks_uri, so a token from one project fails signature verification against every other project’s key set and the root’s, whether or not the resource server checks iss issuer_keys.ts, test/bucket_keys/isolation.spec.ts, test/bucket_addressing/signed_responses_issuer.spec.ts
Algorithm confusion, alg: none JWT access tokens refuse none; symmetric algorithms require a secret key object; the allowed lists are fixed per use jwt.ts, jwaAlgorithms.ts, test/formats/jwt.spec.ts
Client assertion replay or misuse (private_key_jwt) exp, jti, iss and aud are mandatory; iss must equal the client id; aud must be the addressed issuer or the endpoint called, so an assertion naming the root is refused at a project’s address; jti is replay-checked token_jwt_auth.ts, test/client_auth/client_auth.spec.ts
Timing side channel on secret comparison Client secrets, PKCE verifiers, device user codes, registration tokens, TOTP codes and DPoP nonces are compared in constant time; the client secret comparison pads to a floor of a thousand characters constant_equals.ts, test/helpers/constant_equals.spec.ts
Unregistered or over-broad request parameters Routes validate against TypeBox schemas with normalisation off, so an undeclared property is refused rather than silently dropped index.ts
Threat Control Evidence
Password brute force and credential stuffing A per-project, per-identity failure counter with a doubling lockout window, checked before the account is even looked up, and indistinguishable from a wrong password. It has no kill switch, deliberately throttle.ts, test/login_throttle/
Offline cracking of a leaked user table Passwords are hashed with argon2id via Bun.password; federated accounts get an unusable random hash setup.ts, resolve.ts
Phished or reused password Per-project TOTP (RFC 6238) with a ten-failure window per account and a per-interaction attempt cap totp/, test/totp/
Guessing a verification code or reset link Codes are six digits, hashed at rest, capped at five attempts and then refused even when correct; links are 32 random bytes, single use, and the reset challenge is destroyed before the password changes so a replay cannot land verification/challenge.ts, password_reset/challenge.ts, test/email_verification/, test/password_reset/
Replay or CSRF against an upstream sign-in State is single use; the nonce must round-trip in the upstream ID token where the provider issues one; and the authorization code is bound to its request whenever the upstream is known to support it. That support is stated as data for a recognised provider, not inferred from its metadata, because three of the four recognised providers support that binding without advertising a method for it federation/state.ts, verifyIdToken.ts, test/federation/
Impersonating a TLS client with a forged certificate header TLS ends at the proxy, so a client certificate can only arrive in a header. It is read only when mTLS.trustProxyCertificateHeader is on, and never by default. Turning the setting on is the operator’s statement that the proxy sets RFC 9440’s Client-Cert and removes any incoming copy mtls.ts, test/certificate_bound_access_tokens/proxy_header.spec.ts
Server-side request forgery through an address a client supplied The sector document, published key set, back-channel notification endpoints, a client ID metadata document and every request to an upstream identity provider are all fetched through one boundary: every resolved address is checked against the private, loopback and link-local ranges, redirects are followed by hand and re-checked per hop, and each request is bounded in time and in bytes read. A refusal does not report what the target answered egress.ts, test/egress/client_addresses.spec.ts, test/cimd/fetch_guards.spec.ts
An account that keeps access after it is deactivated Deactivating, locking, deleting and “sign out everywhere” each send back-channel logout for every session, then sweep every session, grant, refresh token and opaque access token the account owns. The sweep reads the storage inventory’s owner declarations, so an area added later is swept by construction. A partial sweep answers 500 naming what survived, and repeating the action sweeps again. One predicate, canSignIn, decides at every door whether an account may sign in revoke_access.ts, can_sign_in.ts, test/end_user_lifecycle/deactivation.spec.ts, test/end_user_lifecycle/sweep_completeness.spec.ts
Correlating one user across relying parties Pairwise subject identifiers, salted from a stored secret pairwiseSalt.ts
Threat Control Evidence
An administrator reaching another team’s projects Ownership is by team membership, resolved live on every request; a removal takes effect on the next call. Instance-wide surfaces are super-admin only rbac.ts, test/admin/rbac.spec.ts
Confused deputy: an agent presenting a token issued for another resource The MCP plane accepts only tokens whose audience is itself, and the agent gets exactly its authorising administrator’s permissions, never more principal.ts, test/mcp/roles.spec.ts
A prompt-injected agent deleting something The tool list is an allow-list checked against the admin routes by a test, and the operations withheld from an agent are absent from it. The agent’s token is accepted only through /mcp, never by the management API directly, so nothing withheld or gated can be reached around the tool surface. High-consequence tools need a second call carrying a confirmation token bound to the tool, target, argument hash, principal and agent; see the limitation below on what that confirmation does not cover channel.ts, catalogue.ts, confirm.ts, test/mcp/public_admin_api.spec.ts, test/mcp/catalogue_drift.spec.ts, test/mcp/confirmation_matrix.spec.ts
A tenant claiming another tenant’s MCP server as its protected resource A declared resource is unique only within its issuer’s namespace, so a declaration in one project cannot block, shadow or redirect another’s, and resolution reads only the addressed issuer’s namespace. At the root issuer, which projects without an address of their own share and a resource’s metadata cannot tell apart, only a super administrator declares namespace.ts, resolveBucket.ts, test/resources/namespace_resolution.spec.ts, test/resources/root_namespace.spec.ts
Covering tracks Every state-changing admin route writes an append-only audit entry before the mutation; the store exposes no update or delete, and a test fails when a mutating route is not classified record.ts, test/admin/audit_route_classification.spec.ts, test/admin/audit_secrecy.spec.ts
Secrets leaking through telemetry The error store keeps an allow-list of fields, submitted field names and never values, and the caller address is keyed-hashed and truncated. Sentry receives an allow-listed event shape; an event with an unexpected key is refused, not scrubbed. Nothing in lib/ logs a token, secret or password redact.ts, sentry/dispatch.ts, test/error_store/redaction.spec.ts, test/sentry/redaction.spec.ts, test/mcp/secrecy.spec.ts
An honest or forged address reported as an attacker Attack reports carry only public addresses that crossed a per-hour threshold an honest visitor does not reach, an IPv6 address only as its /64, and nothing the caller sent. The address is the one the caller-address setting trusts, never a header read on the spot. FoxTrust never lists an address on one server’s reports alone. Every field attack_report_fields.ts, address.ts, test/attack_reports/addresses.spec.ts, test/attack_reports/honest_visitors.spec.ts
CSRF against the admin API from a web attacker’s page The admin session cookie is SameSite=Strict, scoped to /admin, and no response ever carries Access-Control-Allow-Credentials session.ts, cors.ts
Threat Control Evidence
Theft of a SCIM credential, or its use against another project A connection’s token is opaque, carries the scim scope and nothing else, and names the addressed project’s SCIM URL as its audience. The SCIM principal refuses a token whose audience or project is not the project addressed, and only an enabled connection of that project may mint one. Static tokens and secrets are stored as digests and shown once. A failed credential is charged at the strict per-origin rate principal.ts, token_policy.ts, test/scim/isolation.spec.ts, test/provisioning/credentials.spec.ts
Mass deprovisioning by a mistaken or compromised directory An optional threshold per connection. The first deprovisioning beyond it holds the connection; while it is held, every deactivation and deletion is answered 429 with Retry-After and applies nothing, and every other request keeps flowing. The project’s administrators are emailed once. No edit ends a hold, only an explicit release, which is a high-consequence MCP tool deprovision_guard.ts, test/deprovisioning_guard/guard.spec.ts, test/deprovisioning_guard/hold.spec.ts
A forged global token revocation or back-channel logout Asymmetric algorithms only, a constant no setting relaxes, so none and HS* are refused whatever a provider advertises. The provider is found by its issuer and our client id there as two separate values, and the token is verified with that provider’s published keys. aud, exp, iat and jti are required, the lifetime is at most 300 seconds measured from iat and from now, and jti is single use. A provider that is not opted in is refused only after it has authenticated, and a user it may not name gets the same 404 as one who does not exist assertion.ts, test/global_token_revocation/refusals.spec.ts, test/upstream_backchannel_logout/refusals.spec.ts
A directory changing an account it does not own, or an administrator undoing a directory A provisioned user and a connection’s group belong to that connection: an administrator’s edit is refused with 409, and a connection that names another connection’s user, or a local one, gets 404. The administrator’s lock is the one local control, and nothing a connection sends reaches it service.ts, test/end_user_lifecycle/managed_users.spec.ts, test/scim/groups_fenced.spec.ts
Threat Control Evidence
Cross-origin reads of token responses CORS is closed by data: an origin may read a client-based endpoint only if the calling application’s own stored record lists it; an unidentifiable client gets no header at all cors.ts, test/cors/
Downgrade, sniffing, referrer leaks Strict-Transport-Security for two years with subdomains, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, a denying Permissions-Policy, and default-src 'none' on every non-HTML response securityHeaders.ts, test/security_headers/
Exhausting the sign-in, token or registration endpoints Per-origin rate limiting tiered by route class, mounted before routing so a refused request costs nothing rateLimit.ts, test/rate_limit/
Threat Control Evidence
Reading secrets from a copy of the database: a backup, a snapshot, a dump, or read-only access to the database Every secret the server must read back is sealed: encrypted with AES-256-GCM under a keyring the operator supplies through KEYRING or a keyring file, and never stored in the database. That covers application secrets, sign-in provider secrets and Apple signing keys, the SMTP password, private signing keys, authenticator secrets including unfinished enrolments, the DPoP nonce secret and the error log’s address key. A completeness test fails when a secret-looking field is neither sealed nor listed as deliberately left alone seal.ts, sealed_fields.ts, test/sealing/declared_fields.spec.ts, test/sealing/inventory_sweep.spec.ts
Moving a sealed value into another record, or editing it Each sealed value is bound to its record and field in its authenticated header, so a value copied elsewhere or altered does not open seal.ts, test/sealing/tampering.spec.ts
A mistyped keyring sealing new values under the wrong key Startup is refused when a keyring gives a known key id different key material, before anything is written startup.ts, test/sealing/startup.spec.ts
A lost key turning a secret into an open door A check that needs a secret it cannot open fails closed: the application is refused as invalid_client, the authenticator is refused, mail and the sign-in provider count as not configured. The console lists each such secret with its repair unreadable.ts, test/sealing/lost_key.spec.ts

These are true today. Each is either a deliberate trade-off with its reasoning, or a gap with its compensating control. A fix that closes one is welcome; a report that one of them is exploitable in a way the compensating control does not cover is a vulnerability report.

  • Stored secrets are encrypted with a keyring the operator holds. That protects a copy of the database, not a running server. Client secrets, signing keys, the SMTP password and the other secrets in Stored secrets are sealed with a key that is never in the database. So a copy of the database, a backup, a snapshot or read-only access to the database yields none of them. An attacker who controls the running server or its environment is a different case: they can read the keyring the same way the server does, and the encryption does not stop them. An ordinary client’s secret is still stored reversibly, because client_secret_jwt and the symmetric keys are derived from it; only a SCIM connection’s secret and static token are stored as digests. Two exceptions remain. The pairwise subject salt is not sealed on purpose: if its key were lost, every application’s sub would change for ever. And an instance without a keyring stores every secret unencrypted, as before, with a warning in the console until the operator supplies one.
  • The rate limiter is a resource protection. It is not a security boundary: counters are in-process, multiply per machine, reset on restart and fail open under an address-rotation flood. The boundaries that must hold absolutely, the sign-in throttle, verification attempt caps and TOTP failure windows, are persisted per identity and do not depend on it.
  • CSRF protection on the admin API and on the sign-in, registration and TOTP screens rests on SameSite cookies alone, with no per-request token. The admin console’s cookie is Strict, since nothing legitimately navigates there from another site; the end-user cookies are Lax, because a relying party’s sign-in is a cross-site top-level navigation and Strict refused it. Lax still withholds the cookie on every cross-site POST and subresource request, which is the shape each of these submissions takes. What it permits that Strict did not is a cross-site top-level GET; no operation reachable that way changes state for anyone but the signed-in user whose own pending interaction it belongs to. Every current browser honours the attribute, and the credentialed CORS path that would bypass it does not exist. The protection assumes the browser honours SameSite.
  • Cookies are opaque random identifiers and carry no signature. Forging one means guessing a session id in the store; nothing on the cookie itself is trusted. The interaction cookie carries a value that never appears in a URL and is compared with the one stored for the interaction, so the interaction’s address alone does not let another browser continue a sign-in.
  • Email-verification link tokens are looked up directly rather than hashed at rest. A reader of the database could redeem a pending link within its twenty-four-hour life; the six-digit codes are hashed.
  • Device-flow user codes have no per-code attempt counter. Entropy is set by the mask, the comparison is constant-time, and the verification screen is in the strict rate class; a dedicated counter would make the code cheaper to give and is the natural next step.
  • The container image runs as root. The process writes no files and binds one port; the compensating control is the platform’s sandbox. Adding a non-root user is straightforward and tracked.
  • TOTP is not enforced for administrators by default. It is a per-project setting, and the first administrator guide says to turn it on before anything else; the default is a choice about first-run friction rather than a limitation of the control.
  • The outbound address check and the request resolve the name separately. A host that answers a public address to the check and a private one to the connection (DNS rebinding) is not stopped by the server alone; closing that needs an egress proxy, which is a deployment control. An application’s jwks_uri and backchannel_logout_uri must use https when they are set or changed, so for those two a rebound name also fails the certificate check; a value stored over http before that rule keeps working.
  • The agent confirms its own destructive calls. The confirmation token for a high-consequence tool is returned to the agent that asked, so an agent following injected instructions can make the second call as readily as the first. It protects against a mistaken call and against one agent or principal spending another’s confirmation, not against the agent itself; the compensating controls are the tool list, which publishes no deletion of a project or team, and the MCP client’s own per-call approval. Approval by a person outside the agent’s reach is the natural next step.
  • Every issuer’s key set, the root’s included, is cached per server instance for up to thirty seconds. There is no messaging between instances, so another instance can lag a key change by that long. Rotation is built around it: a new key is published for twice that long before it may sign, and a retired one stays published for a day.
  • Tokens a project signed with the instance keys, before projects had keys of their own, stop verifying at that project’s resource servers. The switch was immediate, with no period in which the instance keys stayed in a project’s key set, because that period would have kept one tenant’s tokens verifiable at another’s. It was taken while no deployment had users.
  • A JWT access token a resource server validates locally lives until it expires. Deactivation, a lock, deletion and an upstream revocation end every session, grant, refresh token and opaque access token at once, but a self-contained token already in a resource server’s hands cannot be recalled. Keep accessTokenTTL on a declared resource short. It is fifteen minutes unless an administrator raised it, which is what the OpenID IPSIE profile (Interoperability Profiling for Secure Identity in the Enterprise) allows at SL2 for a token not bound to a key.
  • Global token revocation rests on an expired individual draft (draft-parecki-oauth-global-token-revocation-06, never adopted) and has not been run against a real Okta organisation; the test suite replays the request Okta documents, signed with a stub provider’s keys. It is off by default, behind globalTokenRevocation.enabled, for that reason.
  • On a standalone mongod, a change to a group of end users is not atomic. Every refusal is decided before the first write, but a database failure in the middle of a change can leave part of it applied until the directory’s retry converges. PostgreSQL and MongoDB replica sets apply each change in one transaction.
  • dpop.allowReplay exists for deployments that cannot run replay detection. It is absent from the settings console on purpose and defaults to off.

The controls above assume a deployment that: terminates TLS in front of the server with an https:// issuer; chooses the clientAddress.source that matches the proxy in front of it; keeps the database, MongoDB or PostgreSQL, off the public network with authentication on and encryption at rest where available; supplies a keyring through KEYRING or a keyring file and never stores it in the database; turns on the TOTP requirement for the administrators’ project; and backs up the database, because the signing keys are in it. The keyring is backed up separately from the database, because the sealed secrets in a backup cannot be opened without it, and a backup kept beside its keyring loses the protection. Behind a reverse proxy covers the first two, and The keyring covers the keyring.

The evidence column is the maintenance rule: a control listed here without a test is a control that can disappear in a refactor with nothing to say so. When a control changes, this page changes in the same pull request, and the assurance page describes the scanning that runs regardless of anyone remembering to.