Admin PanelAuthentication & RBAC

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.

PropertyDefaultNotes
Cookie namecolyseus_admin_sessionFixed, not configurable
HttpOnlytrueAlways, not configurable
SameSiteStrictConfigurable via session.cookieSameSite ('Strict' | 'Lax' | 'None')
Securetrue in productionAuto-set based on NODE_ENV; override via session.cookieSecure
TTL7 daysConfigurable via session.ttlSeconds
Domainhost-onlySet 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 tokenVersion is bumped (see below).
  • Revocation happens by incrementing the user’s tokenVersion column. 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:

RoleDefault access
adminFull access: all resources, all actions, all rooms.
modList, 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.
userCannot 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");      // revoke

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.

src/app.config.ts
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 typeMissing/invalid sessionInsufficient role
Browser navigation (Accept: text/html)302<loginUrl>?next=<originalUrl>302 → login, session cookie cleared to break redirect loops
XHR / fetch / curl401 JSON body403 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

OptionTypeDefaultDescription
databaseGameDatabaseGameDatabase.currentDatabase to validate the session against.
role'admin' | 'mod' | 'user''admin'Minimum role required. Hierarchy: admin > mod > user.
loginUrlstring'/admin'Where to send unauthenticated browser visits. Match uiPath if you customized it.
apiOnlybooleanfalseForce 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:

  1. User clicks “Forgot password?” on the login screen.
  2. POST /admin-api/auth/request-reset is called with the email.
  3. The panel calls your onResetRequest({ email, userId, token, url }) callback (configured on the admin() options).
  4. The callback delivers the URL, typically via a transactional email service. The token is a short-lived signed JWT bound to that user.
  5. The user opens the URL, which loads the panel’s /reset?token=<token> page.
  6. They submit a new password; the panel calls POST /admin-api/auth/reset to verify the token and update the hash via db.auth.setPasswordHash().
  7. The reset endpoint also bumps tokenVersion to 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.

src/app.config.ts
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.

EndpointDefault limitOption
/auth/login10/min per IP + emailrateLimit.login
/auth/bootstrapburst of 5, refill 1/min per IPrateLimit.bootstrap
/auth/request-reset~1/min per IP + emailrateLimit.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