Back to Directory/Security & Auth

FoxAuth

Administer a FoxAuth OAuth 2.1 / OpenID Connect server: projects, apps, users, keys and settings.

Security & AuthTypeScriptv1.2.0

OAuth-server.ts is a source-available (FSL-1.1-ALv2), standards-compliant authorization server written in TypeScript and powered by Bun and Elysia. It implements OAuth 2.0 and OpenID Connect from the ground up, giving you complete control over your identity infrastructure — with no vendor lock-in.

Features

Most protocol capabilities are governed by a named feature flag, and a flag that is off means the capability's endpoint is not served at all. So this list is split by what you get on a default install versus what you switch on deliberately — the distinction is load-bearing, not editorial.

Out of the box

  • Authorization Code flow with PKCE — PKCE is mandatory for every client, public or confidential, per OAuth 2.1
  • OpenID Connect — OIDC Core 1.0 with ID tokens, UserInfo endpoint, and discovery
  • Authorization-response issuer identification — iss on every authorization response (RFC 9207)
  • Resource Indicators — audience-restricted tokens via resource (RFC 8707)
  • Refresh Token grant — with rotation and reuse detection; offered while offline_access is among the supported scopes, which it is by default
  • RP-initiated logout — by GET or form POST, with a confirmation step
  • JWT tokens — access and ID tokens signed with the algorithms your key store holds; bun run db:setup provisions an initial RS256 key, and ES256 and EdDSA keys are supported. Keys live in the database, are generated on first run, and are published at /jwks — and each project with an address of its own publishes its own at its own jwks_uri
  • Database-backed clients — clients live in this server's own store and are created through the admin API, dynamic registration, or bun run db:setup. There is no static client configuration file
  • Administration console — projects, teams, OAuth clients and APIs, administrators, end-users and their groups, upstream identity providers, SCIM provisioning connections, settings, SMTP, signing keys and an append-only audit trail
  • End-user self-service — email verification and password reset, each with per-identity attempt and cooldown caps
  • TOTP second factor — per-project, optionally enforced for the admin console (RFC 6238)
  • Pairwise subject identifiers — per-sector sub values, salted from the database
  • Per-origin rate limiting — tiered by route class, on by default
  • Sign-in brute-force throttle — persisted per-address failure counters with escalating lockouts, and no way to tell a throttled refusal from a wrong password
  • Security headers — HSTS, Permissions-Policy, framing and content-type protections
  • CORS closed by data — an origin is readable only if it is listed on the project owning the calling client
  • Pluggable storage — adapter architecture with MongoDB, PostgreSQL and in-memory implementations
  • Consent & login UI — built-in authentication and consent screens (React + Ant Design)
  • Scope-based access control — fine-grained, per-client scope enforcement
  • Extensible — 31 named behaviour seams (account lookup, interaction policy, refresh-token rotation, resource resolution, pairwise identifiers, RAR handling and more) replaced at call time via addons.override()

Opt-in capabilities

Implemented, and off until the flag is set. Set them in the console under Settings or on ApplicationConfig; a change saved in the console applies at once.

CapabilityFlag
Client Credentials grantclientCredentials.enabled
DPoP — sender-constrained tokens, including nonces (RFC 9449)dpop.enabled
Pushed Authorization Requests (RFC 9126)par.enabled
Token introspection (RFC 7662)introspection.enabled
Token revocation (RFC 7009)revocation.enabled
JWT-response introspection (RFC 9701)jwtIntrospection.enabled
Signed UserInfo responsesjwtUserinfo.enabled
Dynamic client registration (RFC 7591)registration.enabled
Registration management (RFC 7592)registrationManagement.enabled
Device Authorization Grant (RFC 8628)deviceFlow.enabled
CIBA — backchannel authenticationciba.enabled
JARM — JWT-secured authorization responseresponseMode.jwt.enabled
Request objects (request / request_uri)requestObjects.enabled
Rich Authorization Requests (RFC 9396)richAuthorizationRequests.enabled
mTLS client authentication and certificate-bound tokens (RFC 8705)mTLS.enabled
FAPI profile behavioursfapi.enabled
Backchannel logoutbackchannelLogout.enabled
The claims request parameterclaimsParameter.enabled
Token and response encryptionencryption.enabled
Sign in with Google, Microsoft, Apple or GitHub, or any other OIDC provider by handfederation.enabled
Administration over MCP — the same management API, served to an AI agentmcp.enabled
SCIM 2.0 provisioning of a project's users and groups from Microsoft Entra ID, Okta or any SCIM clientscim.enabled
Okta Universal Logout — global token revocation (an expired individual draft, hence opt-in)globalTokenRevocation.enabled
Error-store recording (its read surface is always served, deliberately)errorStore.enabled

Quick Start

With Docker Compose

Prerequisites: Docker with Compose v2.

curl -O https://raw.githubusercontent.com/RedFox-Soft/OAuth-server.ts/main/docker-compose.yml
docker compose up

That starts MongoDB, provisions the schema and the admin panel (an idempotent one-shot step), and serves the server at http://localhost:3000. Open http://localhost:3000/admin — the first visit shows a one-time setup screen that creates the initial super administrator. Discovery is at http://localhost:3000/.well-known/openid-configuration. Set ISSUER in docker-compose.yml to its final public URL before this first docker compose up: the seeded admin client's redirect URI is derived from it once, and a later change is not repaired by re-running setup.

Images are published to ghcr.io/redfox-soft/oauth-server-ts on every release, tagged with the version and latest. To upgrade, pull and run docker compose up again; the setup step re-runs and is safe to repeat.

From source

Prerequisites: Bun v1.3+ and a running MongoDB or PostgreSQL instance.

git clone https://github.com/RedFox-Soft/OAuth-server.ts.git
cd OAuth-server.ts
bun install

# Create .env — set MONGODB_URI, DATABASE_NAME, and ISSUER (see Configuration)

bun run db:setup   # collections, indexes, initial RS256 signing key, admin panel seed
bun run build      # bundles the console and sign-in screens into public/
bun start          # http://localhost:3000

Schema provisioning creates the initial signing key, so no key configuration is required. If the server ever starts against an empty key store it also generates and persists one automatically.

Admin panel provisioning

bun run db:setup also provisions the admin panel: the reserved "Administrators" project and the first-party admin-panel OAuth client. The seed is idempotent — re-run bun run db:setup after upgrading an existing install so the admin client and project exist. On first visit to /admin, a one-time setup screen creates the first administrator and makes them a member of Super administrators; the setup route is closed once that team has an active member. The admin panel requires a database-backed deployment, MongoDB or PostgreSQL (the in-memory adapter does not persist seeded data across restarts).

Configuration

VariableDescriptionExample
MONGODB_URIMongoDB connection stringmongodb://localhost:27017
DATABASE_NAMEName of the database to useOAuth
ISSUERCanonical URL of your authorization serverhttps://auth.example.com

Signing keys

Signing keys are stored in the database, not in an environment variable — every issuer's in the bucketKeys area, the instance's included. The initial RS256 signing key is created when you provision the schema (bun run db:setup); the server also generates and persists one automatically if it starts against an empty store. Rotation needs no restart: generate a key (it is published first), promote it to sign once every instance serves it, and retire the old one, which keeps verifying for a day — in the console, the admin API, or over MCP (jwks_generate, jwks_promote, jwks_retire). One key signs per algorithm, so RS256 and PS256 can sign side by side.

Rate limiting

Requests are counted per calling origin and refused with 429 once an origin exceeds its allowance inside a window. The refusal happens before the endpoint does any work, and carries Retry-After with the seconds remaining; the body is the standard OAuth error shape, the admin plane's own shape, or an HTML page, depending on which surface was addressed. No RateLimit-* headers are emitted — they would tell a caller probing for the threshold exactly where it is.

Allowances are tiered by route class rather than being one blanket number, because a limit tight enough to protect the token endpoint would refuse the console's asset burst:

ClassApplies toDefault
strictToken, authorization, dynamic registration, PAR, device, CIBA, and every end-user door that checks a secret or sends mail60 / 60s
ordinaryEverything not otherwise classified — userinfo, introspection, revocation, the admin API, MCP, the rest of the interaction screens300 / 60s
publicStatic assets, discovery metadata, the key set, and every cross-origin preflight1200 / 60s
exemptGET /health only — a refused liveness probe would take the machine out of the load balancernever counted

All of it is settable from the admin console under Settings → Rate limiting, or directly on ApplicationConfig. A change saved in the console applies at once, like every other server setting.

SettingDefaultNotes
rateLimit.enabledtrueIncident kill switch
rateLimit.trustedProxytrueSee below — no universally safe value
rateLimit.maxTrackedOrigins10000Per class; caps the limiter's own memory
rateLimit.strict.max60With .windowSeconds, default 60
rateLimit.ordinary.max300With .windowSeconds, default 60
rateLimit.public.max1200With .windowSeconds, default 60

The server refuses to start if any of these is not a positive integer, rather than serving with limiting silently absent or silently total.

rateLimit.trustedProxy is the one setting with a wrong answer in each direction. Leave it on when anything sits in front of this server (the shipped fly.toml does): with it off, every caller arrives as the proxy's own address, so the whole internet shares one allowance and all traffic is refused within seconds. Turn it off when the server is directly exposed: with it on, any caller can set Fly-Client-IP to a fresh value per request and is never limited.

Two properties to know before relying on this. Counting is per instance and held in memory, so with N machines serving concurrently the effective allowance is N times the configured value, and an instance restart clears every counter. This is a resource protection — it bounds what one source can cost the server — and not a security boundary. The limits that must hold absolutely are the per-identity throttles: verification codes cap at five attempts, resends and password-reset requests are cooldown- and daily-capped, and the password sign-in door has the brute-force throttle below — all independently of anything here.

Tuning. Raise strict.max if a legitimate server-to-server integration behind one address, or many users behind one corporate NAT, start seeing refusals. Raise public.max if the admin console stutters while loading. Lower maxTrackedOrigins under memory pressure. Set rateLimit.enabled to false to switch the whole thing off during an incident; it applies on save.

Sign-in brute-force throttle

Failed password attempts are counted per project and address. Once an address reaches the cap, the sign-in door is shut for that address and refuses every further attempt — including one carrying the correct password — until the window ends. Each further exhaustion shuts it for longer, doubling to the ceiling: 15 → 30 → 60 minutes by default, which holds a sustained attack to roughly 120 guesses a day. The counter is forgotten after 24 hours without a failure.

The refusal is the ordinary "invalid username or password" page. It never says that a throttle exists, how many attempts remain, or when the door reopens, because saying any of it would tell an attacker that the address they are guessing is real. For the same reason attempts are counted for addresses that have no account at all — a counter's existence means somebody typed that address, nothing more. And because the refusal happens before the account lookup, a refused attempt costs no password hashing: a flood against one address stops being a CPU cost as well as stopping being a guessing opportunity.

Unlike the per-origin limiter above, these counters are persisted, so the limit holds across restarts and across every machine serving concurrently. That is what makes this a security boundary rather than a resource protection — and why there is deliberately no switch to turn it off.

SettingDefaultNotes
loginThrottle.failureCap5Attempts per window; at most 100
loginThrottle.windowSeconds900First lockout, and the base the curve doubles from
loginThrottle.windowCeilingSeconds3600Longest lockout; never below the first, never over 24h

Two counters are cleared immediately rather than waiting out the window: a password that verifies, and a completed password reset. The second is the escape hatch — consuming the emailed secret proves control of the address, which is exactly what an attacker guessing passwords does not have — so a locked-out user is never stuck waiting. Merely requesting a reset clears nothing.

A project that requires a one-time code stays at the first window however often it is tripped: a guessed password there does not sign anyone in, so the deeper lockouts buy little. This matters most for the reserved Administrators project, whose operators sign in through this same door and have no self-service reset to escape with — their worst case is a 15-minute wait, and the way to make that door properly hard is to require the second factor on it (Admins → Require an authenticator app).

Docker

Published image:

docker pull ghcr.io/redfox-soft/oauth-server-ts:latest
docker run -p 3000:3000 --env-file .env ghcr.io/redfox-soft/oauth-server-ts:latest

Run bun run db:setup against the same database once before the first start (the Compose file above does this for you).

Every published image is signed over its digest, and carries an SBOM and a build provenance attestation. The signing is keyless, so verification names the workflow that is allowed to have produced it rather than a key:

cosign verify \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity-regexp '^https://github\.com/RedFox-Soft/OAuth-server\.ts/\.github/workflows/release\.yml@refs/tags/v' \
  ghcr.io/redfox-soft/oauth-server-ts:latest

Reading the SBOM, the build record and the signed SLSA provenance is three more commands.

To build locally instead:

docker build -t oauth-server-ts .
docker run -p 3000:3000 --env-file .env oauth-server-ts

Endpoints

Most endpoints are governed by a feature flag, and a flag that is off means the endpoint is not served at all — not merely that discovery stops advertising it. The refusal is deliberately indistinguishable from a path the server does not have, so if a request below returns 404, check the flag before suspecting a defect. Flags are set in the admin console under Settings, or directly on ApplicationConfig, and a change saved in the console applies at once.

Always available

EndpointDescription
GET /healthLiveness probe
GET /.well-known/openid-configurationOpenID Connect discovery document (contents reflect enabled capabilities)
GET /.well-known/oauth-authorization-serverAuthorization server metadata for plain OAuth clients (RFC 8414); the same document minus the OpenID-Connect-only members
GET /.well-known/security.txtSecurity contact and disclosure policy (RFC 9116)
GET /jwksJSON Web Key Set
GET, POST /authAuthorization endpoint
POST /tokenToken endpoint

Enabled by default

Flag-governed, but on unless you turn them off.

EndpointDescriptionFlag
GET, POST /userinfoUserInfo endpointuserinfo.enabled
GET, POST /logoutRP-initiated logoutrpInitiatedLogout.enabled
POST /logout/confirmLogout confirmationrpInitiatedLogout.enabled

Opt-in

Absent from a default install until the flag is set.

EndpointDescriptionFlag
POST /parPushed Authorization Requests (RFC 9126)par.enabled
POST /token/introspectToken introspection (RFC 7662)introspection.enabled
POST /token/revocationToken revocation (RFC 7009)revocation.enabled
POST /regDynamic client registration (RFC 7591)registration.enabled
GET /reg/:clientIdRead a client's own registrationregistration.enabled
PUT, DELETE /reg/:clientIdUpdate / delete a registration (RFC 7592)registrationManagement.enabled
POST /device/authDevice authorization (RFC 8628)deviceFlow.enabled
GET, POST /deviceUser-code entry page for the device flowdeviceFlow.enabled
POST /backchannelClient-Initiated Backchannel Authentication (CIBA)ciba.enabled
POST, GET /mcpAdministration over MCPmcp.enabled
GET /.well-known/oauth-protected-resource/mcpProtected resource metadata for /mcp (RFC 9728)mcp.enabled
GET, POST /federation/callbackUpstream identity provider callbackfederation.enabled
/scim/v2/* (Users, Groups and discovery)SCIM 2.0 provisioning, at each project's addressscim.enabled
POST /global-token-revocationOkta Universal Logout, at each project's addressglobalTokenRevocation.enabled

Reading your own registration follows registration.enabled rather than registrationManagement.enabled: the registration response hands the client that URI, so refusing the read while registration is on would break the capability that issued it. Only the mutating methods need the management flag.

Route families

Reached by redirect or through the console rather than called directly, so these are described as families rather than enumerated.

FamilyEntry pointDescription
End-user interaction screens/ui/:uid/…Login, consent, registration, TOTP challenge and enrolment, forgot-password, flow resume. The federation legs (/ui/:uid/federation/…) need federation.enabled
Email verification/verify-emailVerify, code entry, resend
Password reset/reset-passwordSelf-service reset
Administration console + API/admin, /admin/apiProjects, teams, clients, administrators, end-users, federation providers, settings, SMTP, signing keys, audit trail, error store
Static assets/public/…Console assets

The interaction and self-service families are unconditional by design — a user locked out of their account cannot be asked to wait for a capability toggle. The console is unconditional too, even though /mcp is gated: switching MCP off must not take the console with it.

Administration

The server administers itself. GET /admin is a console for projects, teams, OAuth clients, administrators, end-users, upstream identity providers, settings, signing keys and an append-only audit trail. Administrators sign in through an OpenID Connect flow against this server's own issuer, so there is no second password store.

Administration from an AI agent (MCP)

The same management API is available to an AI agent over the Model Context Protocol, as an OAuth 2.1 protected resource of this server. It is off by default — the capability hands an agent the authority of the administrator who authorized it, so a deployment should switch it on deliberately:

  1. In the console, Settings → Administration → Enable the administrative MCP control plane (it applies on save).
  2. Point your MCP client at https://your-server/mcp with client id admin-mcp and resource https://your-server/mcp.

An agent then acts as the administrator who signed in, with exactly that account's permissions. Every change it makes runs through the same routes, the same checks and the same audit trail as the console's, and each audit entry names both the operator and the agent.

A few things it deliberately cannot do: delete a project or a team, purge the fault store, and change which client identities may administer the instance. The deletions destroy a container with nothing left afterwards to inspect, and the last is a privilege-escalation path, so they stay console-only. Everything else destructive takes two steps — the agent describes what would change and you confirm that specific operation.

Which client identity works here. The reserved client_id above always works and needs no setup beyond the toggle. An agent host that cannot be told a client_id can use this surface too, provided it identifies itself with an OAuth Client ID Metadata Document — an HTTPS URL naming a document that describes it — and a super administrator has named that identity under Agent access in the console. A client that registered itself through Dynamic Client Registration cannot administer the instance under any configuration: its identity is minted on demand by whoever asked for it, so there is nothing an operator could meaningfully allowlist.

Protecting your own MCP server

The same protocol surface works for an MCP server that is not this one. Declare it as a protected resource of a project — its canonical URI, the scopes it recognises, how its tokens are verified — and this server mints tokens whose audience is exactly that resource, with no code change here and no restart. An agent host discovers it, signs your end-users in, and comes back with a token your MCP server verifies against the jwks_uri its issuer's metadata names, without asking anything.

Protect your MCP server with OAuth walks it end to end — including what a correct refusal looks like, so you can tell "protected" from "not reached yet".

Implemented Standards

The Flag column names the capability's governing flag; — means always on. See Features for defaults.

SpecificationDescriptionFlag
OAuth 2.1The profile targeted: PKCE for all clients, exact redirect-URI matching, refresh-token rotation, no Implicit or ROPC—
RFC 6749OAuth 2.0 Authorization Framework—
RFC 7636PKCE—
RFC 7519JSON Web Token (JWT)—
RFC 7517JSON Web Key (JWK)—
RFC 9068JWT Profile for OAuth 2.0 Access Tokens—
OIDC Core 1.0OpenID Connect Core—
OIDC Discovery 1.0OpenID Connect Discovery—
OIDC RP-Initiated Logout 1.0A client starts sign-out and gets the user backrpInitiatedLogout.enabled
OIDC Back-Channel Logout 1.0Clients told of sign-out server to server, and upstream providers' sign-outs receivedbackchannelLogout.enabled, federation.enabled
RFC 8414OAuth 2.0 Authorization Server Metadata—
RFC 9207Authorization Server Issuer Identification—
RFC 8707Resource IndicatorsresourceIndicators.enabled
RFC 7009OAuth 2.0 Token Revocationrevocation.enabled
RFC 7662OAuth 2.0 Token Introspectionintrospection.enabled
RFC 9701JWT Response for OAuth Token IntrospectionjwtIntrospection.enabled
RFC 9126Pushed Authorization Requests (PAR)par.enabled
RFC 9449DPoP, including noncesdpop.enabled
RFC 7591Dynamic Client Registrationregistration.enabled
RFC 7592Dynamic Client Registration ManagementregistrationManagement.enabled
RFC 8628Device Authorization GrantdeviceFlow.enabled
RFC 8705mTLS Client Authentication and Certificate-Bound TokensmTLS.enabled
RFC 9396Rich Authorization RequestsrichAuthorizationRequests.enabled
RFC 9728OAuth 2.0 Protected Resource Metadatamcp.enabled
RFC 7643SCIM Core Schemascim.enabled
RFC 7644SCIM Protocolscim.enabled
CIBAClient-Initiated Backchannel Authenticationciba.enabled
JARMJWT-Secured Authorization Response ModeresponseMode.jwt.enabled
RFC 6238TOTP second factor—
Client ID Metadata DocumentA client identified by a URL to a document it hostsclientIdMetadataDocument.enabled

Security

Report a vulnerability to security@foxauth.dev or through private vulnerability reporting; SECURITY.md has the scope, the response times and the safe harbour.

The same file says what stands behind the code: a published threat model, CodeQL, dependency and container scanning on every push and every week (what runs and where to read it), signed images with an SBOM and build provenance, and an OpenSSF Scorecard. It also says what does not exist yet — no external audit, no paid bounty, no OpenID Foundation certification — because a security page that only lists the good news is not one you can trust.

Contributing

Contributions are welcome! Please open an issue or pull request. For significant changes, open an issue first to discuss your proposal.

License

OAuth-server.ts is licensed under the Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2) — the same license Sentry uses. In short: you may use, modify, self-host and redistribute it for any purpose except offering it to others as a competing hosted service, and each version converts to the Apache License 2.0 two years after its release.

This project began in April 2025 as a fork of node-oidc-provider by Filip Skokan, distributed under the MIT License. Its notice is preserved in NOTICE.

Setup from the maintainer

This listing does not have a supported local package template. Use the maintainer’s documentation for its hosted endpoint, authentication, and client-specific setup. No install command has been inferred.

Learn More