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 |
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 |
Sessions are stateless: the cookie is the only source of truth. Two consequences:
- Logout clears the cookie client-side. The JWT itself remains technically valid until it expires unless its
tokenVersionis bumped (see below). - Revocation happens by incrementing the user’s
tokenVersioncolumn.db.auth.bumpTokenVersion(userId)invalidates every session issued before the call on its next verification. The admin “Revoke sessions” action does exactly this;db.auth.ban()does it atomically with the ban itself.
See Database → Authentication for the full revocation story.
Roles
Each admin-panel user has a row in the roles table (set via db.moderation.setRole(userId, role)). 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 (403: an operator role is required), but direct API calls to list/read endpoints are permitted. |
Roles are read from the live DB on every request. A demotion takes effect immediately without re-issuing the cookie.
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:
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"); // revokeThis 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 }),
}),Behavior
The guard sniffs the Accept header to decide how to reject:
| Request type | Missing/invalid 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 |
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. |
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 panel’s forgot-password flow mirrors the player-side flow in @colyseus/auth:
- User clicks “Forgot password?” on the login screen.
POST /admin-api/auth/request-resetis called with the email.- The panel calls your
onResetRequest({ email, userId, token, url })callback (configured on theadmin()options). - The callback delivers the URL, typically via a transactional email service. The
tokenis a short-lived signed JWT bound to that user. - The user opens the URL, which loads the panel’s
/reset?token=<token>page. - They submit a new password; the panel calls
POST /admin-api/auth/resetto verify the token and update the hash viadb.auth.setPasswordHash(). - The reset endpoint also bumps
tokenVersionto terminate any active sessions.
The default onResetRequest forwards through auth.settings.onForgotPassword when configured, falling back to logging the URL via the admin logger. The fallback is fine for local development, but you must override it before going to production. The request endpoint always returns 200 regardless of whether the email exists, so it cannot be used to enumerate users.
admin({
database: db,
onResetRequest: async ({ email, url }) => {
await mailer.send({
to: email,
subject: "Reset your admin password",
html: `<p>Click <a href="${url}">here</a> to reset your password. The link expires in 15 minutes.</p>`,
});
},
}),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 | 10/min per IP + email | rateLimit.login |
/auth/bootstrap | burst of 5, refill 1/min per IP | rateLimit.bootstrap |
/auth/request-reset | ~1/min per IP + email | rateLimit.requestReset |
Pass false per slot to disable a specific limiter (e.g. behind your own WAF), or a custom RateLimiter to swap the implementation:
import { admin } from "@colyseus/admin";
admin({
database: db,
rateLimit: {
login: myRedisLimiter, // your own RateLimiter implementation
bootstrap: false,
},
}),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.