Authentication
gbandit runs a shared auth service at auth.gbandit.com. Players have one account across every game: a guest at first, Google if they sign in. The account lives in a cookie on the auth service. Each game exchanges that cookie for a game token, a JWT scoped to its own project, and verifies it in its backend. You never store passwords or run your own identity.
Frontend: calling your backend as the player
Load the gbandit runtime and call your backend through it:
js
const response = await gbandit.fetch("/api/me");It exchanges the shared cookie for a game token at auth.gbandit.com, sends it as Authorization: Bearer <token>, and keeps it until shortly before it expires. For anything fetch doesn't cover, such as a WebSocket, gbandit.getAccessToken() gives you the token itself.
The token's audience comes from your game's address, so it only works on your deployed domain, and the token is only valid for your project. Under a local dev server the runtime asks the dev server for a test user's token instead (see Local development).
A first-time visitor doesn't need to do anything: the auth service creates a guest (with a generated username) and sets the shared cookie in the same response, so the call above just works. Guest tokens have is_anon: true.
When there is no token to send, gbandit.fetch rejects with a gbandit.GbanditError read from the auth service's Problem Details body: error.is(slug) says which one you got, and error.message is a sentence you can show the player.
The 401 you do need to handle is session-expired. It means the player had a real account here (Google-linked, or one that was merged into another) and its session ended. The auth service won't replace it with a fresh guest, because that would drop a returning player into your game as a stranger with none of their progress. Send them to sign in, and they come straight back on the same account:
js
location.href = gbandit.loginUrl();Sending a guest there is also how they move to Google. With a new Google account the guest keeps its sub, so their progress carries over. If they already have a Google account, the guest is merged into it and they come back with that account's sub; progress your game saved under the guest's sub stays with the guest.
The other answer to send to sign-in is a 429 guest-limit-reached. The auth service caps how many guests one network address may hold at a time, so a scripted flood cannot fill the database with accounts. A large school behind a single address can reach the cap honestly, and a Google account is the way past it. Guest creation is also rate limited per address at the edge; a plain 429 with no problem body is that limit, and retrying after a moment is the right move.
A 403 account-suspended means the platform has suspended this player's account. No token, and no fresh guest, will come from this browser until the suspension is lifted. Show the player that they can't play, rather than retrying or sending them to sign in.
await gbandit.signOut() signs the player out of gbandit and reloads your game, where they start over as a new guest.
Letting players change their name
Players pick their username on the auth service, not in your game. If your game shows it, put an edit button next to it that sends the player to their profile, with redirect pointing back so the page offers a way back to your game:
js
location.href = gbandit.profileUrl();The new name is in the username claim of the next token you mint. A full page load on the way back does that if you keep the token in memory; if you cache it anywhere longer-lived, fetch a fresh one when the player returns.
The token
Tokens are ES256-signed JWTs with these claims:
| Claim | Meaning |
|---|---|
sub | Player id (UUID). The stable identity: key your data on this. It is the same in every gbandit game. Tokens never carry the player's email. |
username | Current display handle. Can change; display it, don't key on it. |
aud | game:<slug> in prod, game:dev-<slug> in dev. Must equal AUTH_AUDIENCE. |
iss | The auth service. Must equal AUTH_ISSUER. |
is_anon | true for guest sessions. |
principal_kind | "human" for players, "test_user" for test users. |
exp, iat, jti | Standard expiry / issued-at / token id. |
Testing as several players
Every project has ten test users, made-up players of its dev environment. gbandit test-token [1-10] prints a token for one, so you can call your dev backend as several players at once:
sh
curl -H "Authorization: Bearer $(gbandit test-token 1)" https://dev-<slug>.gbandit.com/api/me
curl -H "Authorization: Bearer $(gbandit test-token 2 --guest)" https://dev-<slug>.gbandit.com/api/meEach number is the same player every time, with a sub of its own and the username test-user-<n>. --guest gives you the guest with that number instead, test-guest-<n>, a player of its own. A token lasts 30 minutes, and only dev accepts it, since its audience is dev's. Your backend verifies it like any other token: there is nothing to add for test users.
Backend: verifying the token
Your backend receives three environment variables (see the deploy contract): AUTH_ISSUER, AUTH_AUDIENCE, and AUTH_JWKS_URL. Verification is standard JWKS-based JWT validation: fetch the keys from AUTH_JWKS_URL (and cache them), then check signature (ES256), issuer, audience, and expiry.
Node / TypeScript (jose)
js
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL));
async function verifyToken(token) {
const { payload } = await jwtVerify(token, jwks, {
issuer: process.env.AUTH_ISSUER,
audience: process.env.AUTH_AUDIENCE,
});
return payload; // payload.sub is the user id
}Rust (jsonwebtoken)
rust
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
#[derive(Deserialize)]
struct Claims {
sub: String,
username: Option<String>,
is_anon: bool,
}
// Fetch once at startup and cache; refetch on unknown `kid`.
async fn fetch_jwks() -> JwkSet {
reqwest::get(std::env::var("AUTH_JWKS_URL").unwrap())
.await.unwrap().json().await.unwrap()
}
fn verify_token(token: &str, jwks: &JwkSet) -> Result<Claims, String> {
let kid = decode_header(token).map_err(|e| e.to_string())?
.kid.ok_or("missing kid")?;
let jwk = jwks.find(&kid).ok_or("unknown kid")?;
let key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
let mut validation = Validation::new(Algorithm::ES256);
validation.set_issuer(&[std::env::var("AUTH_ISSUER").unwrap()]);
validation.set_audience(&[std::env::var("AUTH_AUDIENCE").unwrap()]);
decode::<Claims>(token, &key, &validation)
.map(|data| data.claims)
.map_err(|e| e.to_string())
}Python (PyJWT)
python
import os
import jwt
jwks_client = jwt.PyJWKClient(os.environ["AUTH_JWKS_URL"])
def verify_token(token: str) -> dict:
key = jwks_client.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
key,
algorithms=["ES256"],
issuer=os.environ["AUTH_ISSUER"],
audience=os.environ["AUTH_AUDIENCE"],
) # ["sub"] is the user idThe subtle bugs
- Keying on
username. Usernames change;subdoesn't. Storesub, displayusername. - Skipping audience validation. Without the
audcheck, a token minted for any other gbandit game verifies against yours. - Rejecting guests.
is_anon: truetokens are valid signed identities. Treat them as players unless your game specifically needs accounts. - Fetching JWKS per request. Cache the key set; refetch only when you see an unknown key id.