Authentication
Every join uses a public project key. For persistent players, add built-in identity or a JWT from your own login system.
| Credential | What it proves | ctx.playerId |
|---|---|---|
| project key (default) | this page may talk to this project | the client id, stable only as long as the resume token |
project key + identity: true | a built-in player account | per-project account ID, durable |
| project key + JWT | who this player is, as your server asserts it | "<iss>:<sub>", durable |
The p_... project id in irtio/schema.ts is the public key. It is not a secret, it is locked to
the project’s origin list, and it belongs in your client bundle. It cannot tell one player from
another across sessions. A token does that.
Use JWT when your game already has accounts. Use built-in identity for accounts managed by irtio. A key-only join is enough for a session shared by a lobby link.
The key-only join
// main.ts
const room = await joinRoom(schema); The key comes from schema.project. The server checks it against the project the socket resolved
to, and checks the browser’s Origin against the project’s origin list.
ctx.playerId here is the client id. It survives a reload for as long as the resume token does. A
player who comes back after it expires is a new player as far as room.kv is concerned. See Player storage.
The JWT join
// main.ts
const room = await joinRoom(schema, {
// A function, not a string: it is called before every HELLO, reconnects included.
token: () => fetch('/api/irtio-token').then((r) => r.text()),
}); token takes a string or a function. Prefer the function. It is called before every HELLO,
reconnects included, so a token minutes from expiring is replaced without your game noticing. A
fixed string is fine for a test and wrong for a session that outlives its exp.
The key still travels. A JWT join is key plus token, not token instead of key. irtio routes on the key, never parses the token, and still applies the origin check.
Setting it up
1. Mint a signing secret. Once per project, per issuer.
irtio keys jwt-secret --project p_0dd0cafe00000004 The secret is printed once and is not stored on your machine. Put it in your own server’s environment. If you lose it, mint another and rotate.
A new secret takes effect the next time your project’s server starts, which means a stop and a start rather than mid-flight. A freshly minted secret does not verify against a server that is already running.
2. Sign tokens on your server, with the claims below.
3. Hand the token to the browser, from an endpoint that already knows who the user is. irtio never sees your login system.
The claims
{
"iss": "main",
"aud": "p_0dd0cafe00000004",
"sub": "user_8123",
"iat": 1787900000,
"exp": 1787903600,
"roomId": "ABCD",
"role": "host"
} | Claim | Required | Checked against |
|---|---|---|
iss | yes | must be an issuer the project has a secret for; ^[a-z0-9._-]{1,64}$, never a colon |
aud | yes | must equal the project id |
sub | yes | 1 to 128 printable ASCII characters, no spaces; becomes the second half of playerId |
exp | yes | unix seconds; rejected once past, with 60 s of leeway |
iat | no | rejected if more than 60 s in the future |
roomId | no | if present, must equal the room actually joined |
role | no | overrides the role the client asked for |
The algorithm is HS256 and only HS256. The server takes the algorithm from the project’s issuer configuration, never from the token header. A token whose header names something else is refused by name.
The server checks the signature before exp and iat, so a forged token learns nothing about the
server’s clock.
The 60-second skew
exp and iat both get 60 seconds of leeway in both directions. A room can be restored from a
snapshot taken at another time, so the clock it reads can jump. Do not mint one-second tokens.
role from a claim
If the token carries role, the client joins with that role, whatever it passed to joinRoom.
With no role claim, the client joins with the schema’s first role, the same as a key join. The
role it asked for is honoured only when the schema sets clientRoles: true.
This is the mechanism behind role-scoped visibility. A client cannot promote itself into a visibility: 'role' collection by asking. It can only present a token that says so, or be granted
the role by room code with room.setRole. See Visibility.
Bind a token to a room
A roomId claim binds a token to one room. It is the right control for “this invite is for this
match”.
Create or choose the room code before minting a room-bound token, then pass that code to joinRoom.
Testing a token by hand
IRT_JWT_SECRET=<secret> irtio keys jwt-mint --sub user_8123 --ttl 900 --role host
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... This signs locally with the exact recipe the server verifies, and the secret never leaves your
machine. The secret can also come from stdin with --secret -, or on the command line with --secret <secret>, which leaves it in your shell history. --iss defaults to main, --ttl to 3600 seconds, and --project to the irtio.json in
the working directory. Use it to reproduce a token refusal. See CLI reference for the full flag list.
Rotation
Two secrets may be active for one issuer at a time, and both verify. That is the whole rotation protocol:
irtio keys jwt-secret # mint the second: both now verify
# move your server to the new secret, and restart your project's server
irtio keys jwt-secret retire # retire the oldest: only the new one verifies A third mint is refused by name while two are active, rather than silently dropping one. Both steps take effect at the next start of your project’s server, so leave a stop and start between “move your server” and “retire”.
Tokens signed with the retired secret stop verifying at that next start. Size your ttl accordingly: a one-hour token minted a minute before you retire is a player who gets refused.
Issuers, and the label in the player id
--issuer names a signing authority. Most projects have exactly one, called main, and never think
about it again. A project with two, such as your own login plus a partner’s, mints a secret per
issuer, and every token says which one signed it.
The label is the first half of ctx.playerId, so main:alice and partner:alice are two different
players with two different sets of room.kv rows. A second issuer can never read the first one’s
storage, and cannot be made to. This is why the label may never contain a colon, and why the regex
is enforced at mint time rather than at verify time.
When a join is refused
Every refusal has its own code, so “auth failed” is never the whole answer. See Error catalogue.
| You see | It means |
|---|---|
E_AUTH | the project key is wrong, or you sent a token to a project with no signing secret |
E_TOKEN_MALFORMED | not three dot-separated segments, or a claim is missing or the wrong type |
E_TOKEN_BAD_ISSUER | iss names an issuer this project has no secret for |
E_TOKEN_BAD_ALG | the header’s alg is not the issuer’s configured algorithm |
E_TOKEN_INVALID | the signature does not verify under any active secret |
E_TOKEN_EXPIRED | exp has passed, or iat is in the future, beyond 60 s |
E_TOKEN_WRONG_PROJECT | aud names a different project |
E_TOKEN_WRONG_ROOM | roomId names a different room than the one being joined |
Two of these are usually configuration problems rather than attacks. E_TOKEN_BAD_ISSUER right
after minting usually means your project’s server has not started again yet. E_AUTH on a token
join means the project has no secret at all, and the error says so. Run irtio keys jwt-secret.
Your login system
Issue JWTs from your existing login service. irtio verifies the token and exposes the asserted identity to your room. For built-in anonymous accounts and account linking, use Player identity.
Related
- Player storage for what
ctx.playerIdkeys, and the cost of adopting JWT late. - Player identity for a durable player id with no server of your own.