A mock OAuth2 / OpenID Connect server you don't have to host

Testing an OIDC login flow usually forces one of two chores. Either you register an app with a real provider โ€” an Auth0/Google/Okta account, a callback-URL allow-list, a client secret smuggled into CI โ€” or you self-host a mock: navikt/mock-oauth2-server wants a JVM or a Docker container, oauth2-mock-server wants a Node process inside your test run, Keycloak in dev mode wants a coffee break. All fine tools โ€” but none of them gives you an issuer URL you can use right now, from CI, a deployed preview, a teammate's laptop, and a mobile build at the same time, with zero infrastructure.

Every Mockbird project is that issuer. It speaks enough of the OIDC spec that real client libraries run their full verification against it โ€” discovery, RS256 signature via a served JWKS, issuer, audience, nonce, expiry โ€” and it accepts any client_id, client_secret, and credentials, because it's a mock IdP for testing login plumbing, not an auth provider.

Try it in 10 seconds (no signup)

The shared demo project is a live issuer right now:

# a standard OIDC discovery document
curl https://mockbird.mockbird.workers.dev/m/demo/.well-known/openid-configuration

# a machine-to-machine access token โ€” any client_id/secret is accepted
curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
  -d 'grant_type=client_credentials&client_id=my-service&client_secret=anything'
# โ†’ {"access_token":"eyJhbGciOiJSUzI1NiIs...","token_type":"Bearer","expires_in":3600}

The token is a real RS256 JWT, signed with the project's own 2048-bit RSA key. Verify it yourself against the JWKS at /m/demo/auth/jwks.json โ€” that's exactly what your library will do.

Your own issuer in one curl

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"preset":"saas"}'
# โ†’ {"id":"x7k2m9qp4w","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/x7k2m9qp4w"}

Your issuer is <baseUrl>; your discovery URL is <baseUrl>/.well-known/openid-configuration. The saas preset seeds a users resource โ€” and that's the identity store: pass ?email= (or login_hint=) at the authorize endpoint, or username= in a password grant, and a matching record becomes the logged-in user โ€” sub is the record id, and /auth/userinfo serves the record's live fields (password-ish fields stripped). Edit a user via plain REST, and the claims change. Unknown email? A user is synthesized, the flow still works.

The full authorization-code flow, with PKCE

# 1. "browser" hits the authorize endpoint โ€” it auto-approves and 302s straight back
curl -si 'https://mockbird.mockbird.workers.dev/m/demo/auth/authorize?response_type=code&client_id=spa&redirect_uri=http://localhost:3000/cb&state=xyz&scope=openid%20profile%20email&email=ada@example.com' \
  | grep -i '^location'
# โ†’ location: http://localhost:3000/cb?code=SINGLE_USE_CODE&state=xyz

# 2. exchange the code (single-use, 10-minute TTL)
curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
  -d 'grant_type=authorization_code&code=SINGLE_USE_CODE&client_id=spa&redirect_uri=http://localhost:3000/cb'
# โ†’ {"access_token":"...","id_token":"...","refresh_token":"...","expires_in":3600}

# 3. standard userinfo
curl https://mockbird.mockbird.workers.dev/m/demo/auth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

PKCE works the way your SPA library expects: send code_challenge + code_challenge_method=S256 at the authorize endpoint and the token endpoint requires the matching code_verifier โ€” a wrong or missing verifier gets a proper invalid_grant. Wrong redirect_uri at exchange? invalid_grant. Reused code? invalid_grant. Your error handling gets real errors to handle.

Point a real library at it

This is the whole configuration for openid-client โ€” no app registration, values are arbitrary:

import * as client from 'openid-client';

const config = await client.discovery(
  new URL('https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT'),
  'any-client-id', 'any-secret'
);

const verifier  = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
const url = client.buildAuthorizationUrl(config, {
  redirect_uri: 'http://localhost:3000/cb',
  scope: 'openid profile email',
  code_challenge: challenge, code_challenge_method: 'S256',
  login_hint: 'ada@example.com',      // pick which users record logs in
});
// ...browser hop โ†’ callback with ?code=...
const tokens = await client.authorizationCodeGrant(config, callbackUrl,
  { pkceCodeVerifier: verifier });
tokens.claims().email;                 // validated id_token claims

NextAuth / Auth.js works the same way โ€” a custom provider with just an issuer:

// auth.config.ts
providers: [{
  id: 'mockbird', name: 'Mock IdP', type: 'oidc',
  issuer: 'https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT',
  clientId: 'nextauth-dev', clientSecret: 'anything',
}]

The library does discovery, redirects through /auth/authorize, exchanges the code, checks the RS256 signature against the JWKS, validates iss/aud/nonce/exp โ€” and everything passes, because everything is real except the security decisions.

The 5-second token: expiry testing for free

curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
  -d 'grant_type=client_credentials&client_id=c&expires_in=5'
# expires_in clamps 5sโ€“7d (default 3600)

Mint a token that dies in 5 seconds, watch your interceptor hit the 401, refresh with grant_type=refresh_token, assert the retry. The refresh grant returns a fresh access + id + refresh token set every time. (Trying to use a refresh token as a bearer token is correctly rejected, too.)

Guard your whole mock API with it

Flip the project to protected mode and every endpoint โ€” REST, GraphQL, SSE, WebSocket โ€” requires a Bearer token, returning proper 401s with WWW-Authenticate otherwise. OIDC access tokens work everywhere the classic /auth/login JWTs do. So your app can do the entire realistic dance against one fake backend: OIDC login โ†’ store tokens โ†’ authenticated API calls โ†’ 401 on expiry โ†’ refresh โ†’ retry.

Honest comparison

Mockbirdnavikt/mock-oauth2-serveroauth2-mock-server (npm)Keycloak (dev mode)Duende demo server
Setupone curl (or nothing โ€” use the demo)JVM dependency or Docker containerNode process in your test runcontainer + realm confignone (public demo)
Hosted URL reachable from CI / previews / mobileโœ”only if you deploy itโœ˜ (in-process)only if you deploy itโœ”
Your own users as identitiesโœ” users resource recordsโœ” configurablepartly (token callbacks)โœ” full user adminโœ˜ fixed demo users
Code flow + PKCE + refresh + userinfoโœ”โœ”โœ”โœ”โœ” (fixed clients)
Arbitrary per-request token claimsโœ˜ (claims come from the user record)โœ” its best featureโœ” via hooksโœ” mappersโœ˜
Consent screens / RBAC / realmsโœ˜ auto-approvesโœ˜โœ˜โœ” it's a real IdPpartial
Works offlineโœ˜โœ”โœ”โœ”โœ˜
Also mocks the API behind the loginโœ” same project: REST + GraphQL + webhooksโœ˜โœ˜โœ˜โœ˜
Price / accountfree, no signupfree, self-hostfree, self-runfree, self-hostfree, shared

Where the others win, plainly: navikt's killer feature is precise per-request control of token contents (arbitrary claims, multiple issuers) and it runs offline inside JVM tests โ€” if you're writing Kotlin/Java integration tests, use it. oauth2-mock-server is the right call for pure-Node offline unit tests. Keycloak is a real IdP โ€” if you need consent screens, roles, or realm import/export, mock nothing and run it. The Duende demo is great for a quick look at certified server behavior, but it's a shared instance with fixed clients and users. Mockbird's angle is different: a keyless hosted issuer per project, where the same base URL also serves the mock API your app calls after login.

What this is not: an auth provider. Every client and credential is accepted by design, tokens are signed with a per-project key that anyone with the URL can get tokens from, and there are no consent screens. Use it to test login flows, guards, interceptors and token lifecycles โ€” never to protect anything real.

Endpoint reference

EndpointWhat it does
GET /m/:project/.well-known/openid-configurationstandard discovery document (issuer = .../m/:project)
GET /m/:project/auth/jwks.jsonpublic RSA key (RS256), standard JWKS shape
GET /m/:project/auth/authorizeauto-approving code flow; email/login_hint picks the user; PKCE S256/plain; errors redirect back per RFC
POST /m/:project/auth/tokengrants: authorization_code, client_credentials, password, refresh_token; form-encoded or JSON; Basic client auth accepted; expires_in 5sโ€“7d
GET /m/:project/auth/userinfostandard claims + live fields from the matching users record
GET /m/:project/auth/logoutend_session_endpoint โ€” redirects to post_logout_redirect_uri

The classic simpler flow โ€” POST /auth/login with any email/password for an HS256 JWT โ€” still exists and is documented in the mock JWT auth guide. Full docs: /docs#oidc.

โšก Skip the terminal: this link creates a live, seeded SaaS backend (users, teams, events, todos) with a working /auth/login in the dashboard โ€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.