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
Path/Always
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

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 its tokenVersion is bumped.
  • Revocation happens by incrementing the user’s tokenVersion column. 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

MethodPathAuthAudit action
GET/admin-api/auth/statusnone
POST/admin-api/auth/bootstrapnone, refused once an admin existsauth.bootstrap
POST/admin-api/auth/loginnoneauth.login, auth.login_failed
GET/admin-api/auth/mesession
POST/admin-api/auth/logoutsessionauth.logout
POST/admin-api/auth/logout-everywheresessionauth.logout
POST/admin-api/auth/request-resetnoneauth.password_reset_requested
POST/admin-api/auth/resetreset tokenauth.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:

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 (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");      // revoke

assignMod() 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.

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 }),
}),

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 typeMissing, invalid or revoked 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 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

OptionTypeDefaultDescription
databaseGameDatabaseGameDatabase.currentDatabase to validate the session against. admin.guard() throws when neither is available.
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 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.

  1. POST /admin-api/auth/request-reset with { email }. The endpoint answers 200 whether 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.
  2. If the email matches a user, the panel signs a short-lived token (15 minutes, not configurable, single-use) and calls your onResetRequest({ email, userId, token, url }) callback.
  3. url is 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.
  4. The page (or your script) calls POST /admin-api/auth/reset with { token, password }. The endpoint verifies the token, writes the new hash via db.auth.setPasswordHash(), then bumps tokenVersion so every existing session for that user is terminated. A second call with the same token fails with 400, because the bump changed the tv the 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.

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="${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.

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