Authentication & RBAC
The admin panel uses session-based JWT cookies for authentication and a three-role hierarchy (admin / mod / user) for authorization. Both reuse @colyseus/database and @colyseus/auth, so revocation, password hashing, and JWT signing follow the same rules as your application’s player auth.
Sessions
When an admin signs in (POST /admin-api/auth/login), the panel sets an HttpOnly cookie carrying a signed JWT with userId, role, iat, exp, and tv (token-version) claims.
| Property | Default | Notes |
|---|---|---|
| Cookie name | colyseus_admin_session | Fixed, not configurable |
HttpOnly | true | Always, not configurable |
Path | / | Always |
SameSite | Strict | Configurable via session.cookieSameSite ('Strict' | 'Lax' | 'None') |
Secure | true in production | Auto-set based on NODE_ENV; override via session.cookieSecure |
| TTL | 7 days | Configurable via session.ttlSeconds |
| Domain | host-only | Set via session.cookieDomain when serving from a subdomain |
There is no server-side session store. Every request verifies the cookie’s signature, then re-reads two columns from the database. The user’s tokenVersion must equal the tv claim, and the role is always the current one. Two consequences:
- Logout (
POST /admin-api/auth/logout) clears the cookie. The JWT itself remains valid until it expires unless itstokenVersionis bumped. - Revocation happens by incrementing the user’s
tokenVersioncolumn.db.auth.bumpTokenVersion(userId)invalidates every session issued before the call on its next verification.db.auth.ban()does the same atomically with the ban itself. The panel’s Revoke sessions and Ban buttons bump the version. They also close the user’s live WebSocket connections in every room they are in.
POST /admin-api/auth/logout-everywhere does both for the signed-in admin: it bumps their tokenVersion and clears the cookie. Use it after a suspected cookie leak.
See Database → Authentication for the full revocation story.
Endpoints
| Method | Path | Auth | Audit action |
|---|---|---|---|
GET | /admin-api/auth/status | none | — |
POST | /admin-api/auth/bootstrap | none, refused once an admin exists | auth.bootstrap |
POST | /admin-api/auth/login | none | auth.login, auth.login_failed |
GET | /admin-api/auth/me | session | — |
POST | /admin-api/auth/logout | session | auth.logout |
POST | /admin-api/auth/logout-everywhere | session | auth.logout |
POST | /admin-api/auth/request-reset | none | auth.password_reset_requested |
POST | /admin-api/auth/reset | reset token | auth.password_reset_completed |
/auth/status returns { needsBootstrap, authenticated, userId, role } and drives the SPA’s first-run and login redirects. Every auth event lands in the audit log with resource: "auth", including failed logins with the attempted email, IP and user agent.
Roles
Each admin-panel user has a row in the roles table (set via db.moderation.setRole(userId, role)). A user without a row counts as user. Three roles exist:
| Role | Default access |
|---|---|
admin | Full access: all resources, all actions, all rooms. |
mod | List, read, and update, only on resources assigned via db.moderation.assignMod(). No create or delete, no access outside assigned scopes, and the roles table is always blocked. |
user | Cannot open the panel UI (the catalog and dashboard answer 403: an operator role is required). Direct API calls to list and read endpoints still succeed. |
Roles are read from the live DB on every request. A demotion takes effect immediately without re-issuing the cookie.
POST /admin-api/auth/login issues a session to any user row that has a password, whatever its role. Combined with the user row above, a registered player can call GET /admin-api/users or GET /admin-api/rooms/:roomId. Those answers contain other players’ emails and live room state. Until the package closes this gap, keep the panel off the player-facing hostname. A network-level guard of your own in front of /admin-api also works.
Promoting a user to admin
From anywhere with access to the database instance (e.g. a one-off script or an admin-only endpoint):
await db.moderation.setRole(userId, "admin");The first admin is created by the bootstrap flow on first visit; subsequent admins are promoted from the panel’s user-management UI.
Scoping a moderator to specific resources
Mods get mod-level access only on the resources they’re assigned to. Assignments are per-resource and use the canonical table key (guilds, userNotes, rooms), not the SQL table name:
await db.moderation.assignMod(userId, "guilds"); // mod on `guilds`
await db.moderation.assignMod(userId, "leaderboards"); // also mod on leaderboards
await db.moderation.unassignMod(userId, "guilds"); // revokeassignMod() promotes a plain user to mod on the first assignment. unassignMod() never demotes: a mod with no scopes left keeps the role and can still open the panel, so call setRole(userId, "user") when you want them out.
This pattern lets you give a community manager full control over guilds without exposing every other table. See Resources & CRUD → Policies for how to declare per-action role gates on individual resources.
admin.guard()
The admin.guard() middleware checks the same session cookie used by the panel itself. You can therefore gate other HTTP routes (the Monitor, the Playground, or any custom endpoint) behind a single admin login.
import { admin } from "@colyseus/admin";
import { playground } from "@colyseus/playground";
import { monitor } from "@colyseus/monitor";
routes: createRouter({
...playground({ use: [admin.guard()] }),
...monitor({ use: [admin.guard({ role: "mod" })] }),
...admin({ database: db }),
}),The same middleware works on your own endpoints:
createEndpoint("/ops/reindex", { method: "POST", use: [admin.guard()] }, async (ctx) => {
// only admins reach this point
});Behavior
The guard decides how to reject from the Accept header. A request counts as a browser navigation when Accept contains text/html (a bare */* does not).
| Request type | Missing, invalid or revoked session | Insufficient role |
|---|---|---|
Browser navigation (Accept: text/html) | 302 → <loginUrl>/?next=<originalUrl> | 302 → login, session cookie cleared to break redirect loops |
| XHR / fetch / curl | 401 JSON body | 403 JSON body naming both roles: requires role 'admin' or higher (you have 'mod') |
next carries the original path and query string. The guard omits it when the path is not a plain absolute path, so an open redirect can’t be smuggled in.
The “cookie cleared” behavior on insufficient role is intentional. A lower-role user visiting /monitor (gated to mod or higher) still holds a valid session cookie. The login page would otherwise treat them as already authenticated and redirect them right back through the guard, creating a loop. Clearing the cookie forces a real re-authentication.
Options
| Option | Type | Default | Description |
|---|---|---|---|
database | GameDatabase | GameDatabase.current | Database to validate the session against. admin.guard() throws when neither is available. |
role | 'admin' | 'mod' | 'user' | 'admin' | Minimum role required. Hierarchy: admin > mod > user. |
loginUrl | string | '/admin' | Where to send unauthenticated browser visits. Match uiPath if you customized it. |
apiOnly | boolean | false | Force JSON responses even for browser navigations. Set to true when wrapping non-HTML endpoints. |
Password reset
The reset flow is implemented on the API, but the panel ships no reset page yet. The login screen has no “Forgot password?” link and the SPA has no /reset route. You drive the two endpoints from your own page, or from a script when an admin is locked out.
POST /admin-api/auth/request-resetwith{ email }. The endpoint answers200whether or not the email exists, so it can’t be used to enumerate users. Only a missing email (400) and the rate limit (429) answer differently.- If the email matches a user, the panel signs a short-lived token (
15minutes, not configurable, single-use) and calls youronResetRequest({ email, userId, token, url })callback. urlis the site-relative path<uiPath>/reset?token=<token>. Nothing prepends your public origin, so the callback has to build the absolute link, and the page at that path is yours to provide.- The page (or your script) calls
POST /admin-api/auth/resetwith{ token, password }. The endpoint verifies the token, writes the new hash viadb.auth.setPasswordHash(), then bumpstokenVersionso every existing session for that user is terminated. A second call with the same token fails with400, because the bump changed thetvthe token was signed with.
The default onResetRequest forwards through auth.settings.onForgotPassword when configured. It renders html/admin-reset-password-email.html from your project when present, and the standard reset-password-email.html otherwise. Both use the [LINK] placeholder (see custom templates). Without that hook it logs the link through the admin logger, which is fine for local development only.
admin({
database: db,
onResetRequest: async ({ email, url }) => {
await mailer.send({
to: email,
subject: "Reset your admin password",
html: `<p>Click <a href="${process.env.PUBLIC_URL}${url}">here</a> to reset your password. The link expires in 15 minutes.</p>`,
});
},
}),Until you host a reset page, an operator can complete the flow from a terminal:
curl -X POST http://localhost:2567/admin-api/auth/request-reset \
-H 'content-type: application/json' -d '{"email":"ops@example.com"}'
# read the token from the logged link (or the email), then:
curl -X POST http://localhost:2567/admin-api/auth/reset \
-H 'content-type: application/json' -d '{"token":"<token>","password":"new-password"}'Rate limits
The auth endpoints are rate-limited to slow down brute-force attacks. Defaults are in-memory token buckets per limiter, sufficient for single-process deployments. Replace with a Redis-backed RateLimiter for multi-node setups so the buckets are shared across instances.
| Endpoint | Default limit | Option |
|---|---|---|
/auth/login | burst of 10, refill 10/min, per IP + email | rateLimit.login |
/auth/bootstrap | burst of 5, refill 1/min, per IP | rateLimit.bootstrap |
/auth/request-reset | burst of 3, refill 1/min, per IP + email | rateLimit.requestReset |
A blocked request receives 429 with a Retry-After header. A limiter is any object with one method; null lets the request through, a Response blocks it:
interface RateLimiter {
check(key: string): Response | null | Promise<Response | null>;
}Each slot accepts false to disable that limiter (e.g. behind your own WAF). It also accepts a tighter in-memory bucket from the exported createTokenBucketLimiter(), or your own implementation:
import { admin, createTokenBucketLimiter } from "@colyseus/admin";
admin({
database: db,
rateLimit: {
login: createTokenBucketLimiter({ capacity: 5, refillPerSec: 1 / 30, retryAfterSec: 30 }),
requestReset: myRedisLimiter, // your own RateLimiter implementation
bootstrap: false,
},
}),The bucket keys use the client IP as seen in X-Forwarded-For or X-Real-IP when present (ipFromHeaders() is exported if your limiter needs the same value). The in-memory map keeps one bucket per distinct key and is never pruned. A long-lived single process exposed to the open internet should prefer the Redis-backed variant.
Next steps
- Resources & CRUD: per-resource policies (the third layer of access control, on top of role + scope).
- Hardening for production: cookie domain, secrets rotation, and email delivery.