Authentication
Grids uses Discord OAuth for user authentication, with JWT tokens for API access and a device-code flow for linking the game client to a web account.
Discord OAuth Flow
1. Initiate Login
GET /auth/discord
Redirects the user to Discord's authorization page. After approval, Discord redirects back to:
GET /auth/discord/callback?code=...
2. Callback
The callback exchanges the authorization code for a Discord access token, fetches the user profile, and creates or updates the user in the D1 database.
Response: Returns a JWT token and redirects to the web app.
3. JWT Token
Tokens use HS256 signing with a 24-hour expiry. The payload contains:
{
"sub": "user_id",
"discord_id": "123456789",
"display_name": "PlayerName",
"iat": 1712000000,
"exp": 1712086400
}
Discord Membership Gate
Playing Grids requires being a member of the official Grids Discord server — this acts as the platform allow-list. Membership is checked server-side using a Discord bot token (GET /guilds/{id}/members/{discord_id}), so no extra OAuth scope is requested from players.
Enforcement happens at two points:
- Login — during
/auth/discord/callback, a non-member is never issued a JWT. They are redirected to/auth/gate, a page that links to the Discord invite and lets them retry once they've joined. - Entering a world —
GET /api/zones/:id/joinandPOST /api/zones/:id/visitre-check membership, so a player who leaves or is removed from the Discord loses access on their next attempt.
When the gate blocks a world entry, the API responds:
{
"error": "discord_membership_required",
"invite_url": "https://discord.gg/..."
}
The check fails open on transient Discord errors (network, rate limit, 5xx) so an outage never locks out legitimate players, and fails closed only on a definitive "not a member" (Discord 404). The gate is disabled automatically if DISCORD_BOT_TOKEN / DISCORD_GUILD_ID are not configured.
Mid-session enforcement
Dedicated servers re-check membership for connected players on a timer and eject anyone who has left the Discord — this makes the Discord a live allow-list rather than a login-only check. The server calls an operator-authed endpoint:
POST /api/zones/servers/membership-check
Authorization: Bearer op_<key_id>.<secret>
Content-Type: application/json
{ "player_ids": ["<grid_user_id>", "..."] }
Response: the subset of IDs that are definitively no longer members (transient errors are omitted, so a Discord blip never kicks anyone):
{
"blocked": ["<grid_user_id>"],
"invite_url": "https://discord.gg/..."
}
The game client surfaces the same friendly "join our Discord" gate in-game, returns the player to the main menu, and offers a one-click retry after they rejoin.
Device Linking
The device-code flow lets the game client authenticate without a browser embedded in the game.
Step 1: Generate Code
From the game client:
POST /auth/device/request
Response:
{
"code": "ABC123",
"expires_in": 600,
"interval": 5,
"verification_url": "https://a-new-world.com/auth/link?code=ABC123"
}
Codes are 6-character alphanumeric and expire after 10 minutes. When a code expires, the game client requests a fresh one automatically and updates the QR code / link shown on the login screen — a browser tab opened with an old code will not work.
Step 2: User Links
The player visits the verification_url in their browser, logs in via Discord, and the page links the device code to their account:
POST /auth/device/verify
Content-Type: application/json
{ "code": "ABC123", "token": "<web_jwt>" }
Response (success):
{ "ok": true }
Errors:
| Status | Body | Meaning |
|---|---|---|
404 | { "error": "code not found" } | Code never existed (typo) |
409 | { "error": "code already used" } | Code was already linked to an account |
410 | { "error": "code expired" } | Code is older than 10 minutes — use the fresh code now shown in the game |
401 | { "error": "invalid token" } | Web session JWT is invalid or expired |
Step 3: Game Polls
The game client polls the API with the device code until the link is confirmed:
POST /auth/device/poll
Content-Type: application/json
{ "code": "ABC123" }
Response (success — code is consumed and deleted):
{
"status": "complete",
"token": "<jwt_token>"
}
Response (pending, HTTP 202):
{
"status": "pending"
}
Response (expired, HTTP 410): { "error": "expired" } — the game requests a new code and continues polling.