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"
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:
GET /m/<id>/users) and the returned user object is that record β same id, same fields, password-ish fields stripped.POST /auth/register inserts a real record into the users resource. The new user shows up immediately in REST, GraphQL, and the dashboard β so you can build a full signup β profile page flow against real state.(customers, accounts, and members resources are recognized as the user table too.)
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.
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.
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.
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.
| Approach | The catch |
|---|---|
| Hardcoded fake token | Never expires, never 401s β the interesting code paths stay untested |
| MSW / handler mocks | Local only; you hand-write the JWT logic and it drifts from real behavior |
| reqres.in | Now 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 provider | Config, API keys, rate limits β heavy for "does my redirect work" |
| Mockbird mock auth | Hosted, 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 β