Extracting Auth Into a Microservice: Shared Identity Across Games
Authentication had the same problem as the AI stack before we extracted it: it lived inside one app. The puzzle server owned the users collection, signed JWTs, validated passwords, managed guest sessions. That was fine when there was one game. Add Memory Match, and you have a choice: duplicate the whole thing, or share it.
Duplicating means two user collections, two JWT secrets to keep in sync, two implementations that drift apart. Not acceptable for a platform.
So we extracted auth the same way we extracted AI: into a standalone microservice that every game calls.
The Architecture
puzzle.kidsgamesapp.com
└─► puzzle server (:3001)
├─► auth-service (:3003) — for auth/profile routes
├─► ai-service (:3002) — for image generation
└─► MongoDB — for progress, puzzle pool, items
memory-match.kidsgamesapp.com (coming soon)
└─► memory-match server
└─► auth-service (:3003) — same service, same users
services/auth is a Bun/TypeScript microservice that owns one thing: user identity. It handles guest sessions, email registration, login, and profile updates. It signs JWTs. It owns the users MongoDB collection.
Individual game backends proxy auth requests to it. They receive tokens in the response body, set cookies locally, and validate JWTs locally for every subsequent request — without calling the auth service per request.
The Cookie Problem (And Why It’s Not Auth-Service’s Problem)
Early in the design, we considered having auth-service set cookies directly. Convenient, but it doesn’t work: a service at auth.kidsgamesapp.com can’t set cookies for puzzle.kidsgamesapp.com or memory-match.kidsgamesapp.com without a shared parent domain and careful cookie configuration. It becomes a browser-policy problem, not an engineering problem.
The cleaner design: auth-service is domain-agnostic. It returns tokens in the response body.
// POST /auth/guest → 200
{
"success": true,
"data": {
"user": { "_id": "...", "isGuest": true, "avatar": "🧒" },
"token": "eyJhbGc..."
}
}
The calling server (puzzle, memory-match, whatever) receives the token, sets an httpOnly cookie on the appropriate domain, and manages the session. The auth service never touches cookies. It doesn’t know what domain you’re on.
The JWT Strategy
We chose shared-secret JWT validation over centralized validation. The two options:
Option A — Centralized: Every authenticated request proxies to auth-service for token validation. Simple, but adds latency to every request and creates a hard dependency on auth-service uptime.
Option B — Shared secret: Auth-service signs tokens with JWT_SECRET. Game servers validate tokens locally using the same secret. Auth-service is only called for login/logout/profile — not for every request.
We went with Option B. The same JWT_SECRET is deployed to both services. The puzzle server validates tokens itself:
// apps/puzzle/server/src/auth.ts
export function verifyToken(token: string): JwtPayload | null {
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
const [header, payload, sig] = token.split(".");
const hmac = new Bun.CryptoHasher("sha256", JWT_SECRET);
hmac.update(`${header}.${payload}`);
const expected = hmac.digest("base64")
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
// check expiry, return payload
}
No network call. No auth-service dependency at request time. If auth-service is down, users who are already logged in keep working.
The timingSafeEqual call is worth noting — comparing HMAC signatures with === is vulnerable to timing attacks. Node’s crypto.timingSafeEqual does a constant-time comparison regardless of where the strings first differ. Small detail, correct behavior.
The Proxy Pattern
The puzzle server’s auth routes became thin proxies. Before:
// before — puzzle server owned auth logic
export async function guest(req: Request): Promise<Response> {
const user = await User.create({ isGuest: true, guestId: ... });
const token = signToken(String(user._id));
return setTokenCookie(Response.json({ success: true, data: toIUser(user) }), token);
}
After:
// after — puzzle server proxies to auth-service
export async function guest(req: Request): Promise<Response> {
const body = await req.json().catch(() => null);
const result = await callAuth<{ user: IUser; token: string }>("POST", "/auth/guest", { body });
return setTokenCookie(
Response.json({ success: true, data: result.user }),
result.token
);
}
The proxy client (authProxy.ts) handles the mechanics:
export async function callAuth<T>(
method: string,
path: string,
options: { body?: unknown; token?: string | null } = {}
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": process.env.AUTH_SERVICE_API_KEY ?? "",
};
if (options.token) headers["Authorization"] = `Bearer ${options.token}`;
const res = await fetch(`${process.env.AUTH_SERVICE_URL}${path}`, { ... });
const json = await res.json();
if (!json.success) throw new AppError(res.status, json.error ?? "Auth service error");
return json.data;
}
For routes that need auth (like PUT /api/profile), the puzzle server extracts the cookie token and forwards it as a Bearer header. Auth-service re-validates the token independently — a second check that makes the auth service’s response trustworthy even if the puzzle server’s middleware had a bug.
The Service Itself
services/auth is a lightweight Bun/Mongoose service. The entire thing is ~400 lines of TypeScript.
Routes:
GET /health — health check (no API key required)
POST /auth/guest — create or resume a guest session
GET /auth/me — get current user from Bearer token
POST /auth/logout — stateless (tokens are self-contained)
POST /auth/register — create account with email + password
POST /auth/login — verify credentials, return token
PUT /auth/profile — update name, avatar, age, levelOverride
All routes except /health require an x-api-key header. If AUTH_SERVICE_API_KEY isn’t set in the environment, the service refuses all requests with 500 rather than silently accepting unauthenticated requests. One less footgun.
The User model gained one field compared to the puzzle server’s version:
passwordHash: { type: String, select: false } // bcrypt, never returned
select: false means Mongoose never includes it in query results unless explicitly requested. The login handler is the only place that opts in:
const user = await User.findOne({ email }).select(USER_DEFAULT_SELECT + " passwordHash");
const valid = await Bun.password.verify(parsed.password, user.passwordHash);
Bun.password is built-in bcrypt — no extra dependency. Hash on register, verify on login.
Guest expiry: Guest users get a TTL index that auto-deletes records after 90 days of inactivity:
userSchema.index(
{ updatedAt: 1 },
{
expireAfterSeconds: 90 * 24 * 60 * 60,
partialFilterExpression: { isGuest: true },
}
);
partialFilterExpression scopes the TTL to guest records only. Registered users are never auto-deleted.
Testing the Proxy Chain
The interesting testing challenge: puzzle server tests now need auth-service to be running. The puzzle server’s routes call callAuth(), which is a real HTTP call. Mock it and you’re not testing the actual behavior.
The solution: start auth-service in-process before the puzzle server, sharing the same MongoDB connection.
// apps/puzzle/server/src/__tests__/setup.ts
export async function startServer() {
// auth-service first — connectDB() is idempotent
const { createServer: createAuthServer } = await import(
"../../../../../services/auth/src/index"
);
authServer = await createAuthServer(authPort);
// puzzle server reuses the existing mongoose connection
await connectDB();
server = Bun.serve({ port, async fetch(req) { ... } });
}
createServer() is exported from services/auth/src/index.ts specifically for this pattern. It calls connectDB() internally, which is idempotent — if the connection already exists (from the puzzle server’s own connectDB()), it returns immediately.
Both services share one MongoDB instance in tests. Collections are cleared between tests. No Docker, no mocking, real network calls between real services.
The test count went up: 18 puzzle server tests + 23 auth-service tests = 41 tests in CI, all exercising the actual proxy flow.
What Deleting the User Model Felt Like
The moment apps/puzzle/server/src/models/User.ts got deleted was the clearest sign the migration worked.
git rm apps/puzzle/server/src/models/User.ts
grep -r "models/User" apps/puzzle/server/src/
# no output
The puzzle server no longer imports User anywhere. It doesn’t know how users are stored. It doesn’t know what fields they have. It knows how to call auth-service and how to set cookies. That’s all it needs to know.
What the Next Game Gets for Free
When Memory Match ships, its backend will:
- Deploy with
AUTH_SERVICE_URLandAUTH_SERVICE_API_KEYin its environment - Copy
authProxy.ts(or import a future@kids-games/server-utilspackage) - Wire up the same five proxy routes: guest, me, logout, register, login
- Never write a line of auth logic
Users who already have a puzzle session will have a Memory Match session automatically — same JWT secret, same users collection, same identity. No re-login required. No account linking. One user, multiple games.
The auth service, the AI service, the shared packages — these aren’t interesting on their own. They’re interesting because of what they make possible. Each extraction is a tax paid once so the next game starts at a higher floor. We’re three services and two packages into that investment. The platform is taking shape.