← All guides

Mock a JWT auth API: test login flows before your backend exists

The login screen is usually the first thing you build and the last thing you can actually test. Until the real auth service exists you're stuck with a hardcoded if (email === "test@test.com"), a fake token string that never expires, or MSW handlers that drift from reality. Meanwhile the genuinely tricky parts of client-side auth β€” storing the token, attaching it to requests, catching 401s, redirecting to login when a token expires mid-session β€” go untested until integration week.

Every Mockbird project ships with a hosted mock auth API. Any email + password logs in, and what comes back is a real signed JWT β€” it carries iat/exp claims, validates, and expires for real. Try it right now on the public demo, no signup:

curl -X POST https://HOST/m/demo/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"dev@example.com","password":"anything"}'
# β†’ {"token":"eyJhbGciOi...","tokenType":"Bearer","expiresIn":3600,"user":{...}}

Then use it like production:

TOKEN=$(curl -s -X POST https://HOST/m/demo/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"dev@example.com","password":"pw"}' | jq -r .token)

curl https://HOST/m/demo/auth/me -H "Authorization: Bearer $TOKEN"

Your own project: users are real records

One request creates a seeded backend with a users resource (the saas preset gives you users, teams, events, and todos):

curl -X POST https://HOST/api/projects \
  -H 'content-type: application/json' \
  -d '{"name": "my app", "preset": "saas"}'

The mock auth is wired into your data, not bolted on:

(customers, accounts, and members resources are recognized as the user table too.)

Testing token expiry in 5 seconds, literally

Expired-token handling is the classic "works until it doesn't" bug: everything is fine in dev because your token lives longer than your session. Ask for a short-lived token and watch your interceptor actually run:

curl -X POST https://HOST/m/<id>/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"dev@example.com","password":"x","expiresIn":5}'
# 5 seconds later:
curl -i https://HOST/m/<id>/auth/me -H "Authorization: Bearer $TOKEN"
# β†’ 401 {"error":"token expired"}

expiresIn accepts 5 seconds to 7 days (default 1 hour). Tampered tokens get 401 invalid signature β€” the same two failure shapes your production API will send.

Protected mode: make every endpoint require auth

By default your mock endpoints stay open. Flip protected mode (dashboard β†’ Mock auth card, or the API) and the whole project β€” REST, nested routes, GraphQL β€” starts demanding a Bearer token:

curl -X PUT https://HOST/api/projects/<id>/settings \
  -H 'content-type: application/json' -H 'x-admin-key: <adminKey>' \
  -d '{"authMode":"protected"}'

curl -i https://HOST/m/<id>/users
# β†’ 401, WWW-Authenticate: Bearer, {"error":"missing bearer token", "hint":"...login URL..."}

The /auth/* endpoints and openapi.json stay open so you can always log in, and the exported OpenAPI spec automatically gains a bearerAuth security scheme β€” codegen'd clients know auth is required.

A realistic client, end to end

Here's the whole pattern your app needs, exercised against the mock β€” a fetch wrapper that attaches the token and bounces to login on 401:

const API = "https://HOST/m/<id>";

async function login(email, password) {
  const r = await fetch(`${API}/auth/login`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ email, password, expiresIn: 30 }), // short, to test expiry
  });
  const { token } = await r.json();
  localStorage.setItem("token", token);
}

async function api(path, opts = {}) {
  const r = await fetch(API + path, {
    ...opts,
    headers: { ...opts.headers, Authorization: `Bearer ${localStorage.getItem("token")}` },
  });
  if (r.status === 401) {           // expired or missing β†’ back to login
    location.href = "/login";
    return;
  }
  return r.json();
}

await login("dev@example.com", "pw");
const users = await api("/users");   // works…
// wait 30s, call again β†’ your 401 branch runs. That's the test.

CORS is on for every endpoint, so this runs from localhost, CodePen, or a deploy preview with no proxy. Every call (including the 401s) shows up in the request inspector so you can watch your interceptor behave.

What the tokens are (and aren't)

Tokens are HS256 JWTs signed with a per-project secret β€” they genuinely verify and expire, and tokens from one project are rejected by another. But this is a mock: any password is accepted by design. Use it to build and test client-side auth flows, not to protect anything real.

Why this instead of…

ApproachThe catch
Hardcoded fake tokenNever expires, never 401s β€” the interesting code paths stay untested
MSW / handler mocksLocal only; you hand-write the JWT logic and it drifts from real behavior
reqres.inNow requires an x-api-key header on every request; fixed token string, register doesn't persist β€” see the reqres alternative guide
Spinning up a real auth providerConfig, API keys, rate limits β€” heavy for "does my redirect work"
Mockbird mock authHosted, zero setup, real JWT mechanics, wired to your mock data β€” free

Create a project (no signup needed), point your login form at it, and ship the auth UX before the auth backend exists. Get started β†’