Authentication
irtio accepts two kinds of credential, and every project starts with the first one.
| credential | what it proves | ctx.playerId |
|---|---|---|
| project key (default) | this page is allowed to talk to this project | the client id, stable only as long as the resume token |
| project key + JWT | and who this player is, as your server asserts it | "<iss>:<sub>", durable |
The key is not a secret and never was: the p_... project id in irtio/schema.ts is the public
key, it is domain-locked by the project’s origin list, and it belongs in your client bundle. What
it cannot do is tell one player from another across sessions. That is what a token is for.
Adopt JWT when you have a real notion of a player account: saved progress, purchases, a persistent name, a ban list. If your game is a lobby link people open and close, you do not need it.
The key-only join, unchanged
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. That is the whole check.
ctx.playerId here is the client id. It survives a reload for as long as the resume token does,
and 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
const room = await joinRoom(schema, {
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 that is 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: the router still routes on the key and never parses the token, and the origin check still applies.
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.
The tenant picks a new secret up at its next placement, meaning a stop and a start, not mid-flight. A freshly minted secret does not verify against a tenant 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, and does not want to.
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 |
Algorithm is HS256 and only HS256. The algorithm is taken from the project’s issuer configuration, never from the token header: a token whose header names something else is refused by name rather than trusted.
Signature is checked before exp and iat, deliberately, 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. This is not politeness toward
sloppy clients: a room runs inside a VM that can be restored from a snapshot taken at another time,
and the guest clock is the one thing on this platform known to jump. Do not mint one-second tokens.
role from a claim
If the token carries role, it wins over whatever the client passed to joinRoom. It has to: a
role the client can choose is not a role your server asserted. Absent, the client’s requested role
applies exactly as before.
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. See Visibility.
roomId, and one thing it cannot do
A roomId claim binds a token to one room. It is the right control for “this invite is for this
match”.
It cannot bind a token to a room that does not exist yet. joinRoom with no room code creates one, and a freshly created code can never match a claim minted earlier. If you want room-bound
tokens, create or choose the room code first (your lobby already knows it), then mint against it.
Testing a token by hand
$ irtio keys jwt-mint --secret <secret> --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. --iss defaults to main, --ttl to 3600 seconds, --project to the irtio.json in
the working directory. Use it to reproduce a refusal before blaming your server. 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 ...
$ irtio keys jwt-secret retire # retire the oldest: only the new one verifies A third mint is refused while two are active, by name, rather than silently dropping one. Both steps take effect at the tenant’s next placement, so leave a stop and start between “move your server” and “retire”.
Tokens signed with the retired secret stop verifying at that next placement. Size your ttl accordingly: a one-hour token minted a minute before you retire is a player who gets refused.
Issuers, and why the label is 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 (your own login, plus a partner’s) mints a secret per
issuer, and every token says which one signed it.
The label is not bookkeeping: it 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 trusted 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 worth reading as configuration problems rather than attacks: E_TOKEN_BAD_ISSUER right after minting usually means the tenant has not been placed again yet,
and E_AUTH on a token join means the project has no secret at all
(irtio keys jwt-secret is the fix, and the error says so).
What irtio deliberately does not do
No user accounts, no password storage, no OAuth, no session cookies, no “log in with” buttons. irtio verifies an assertion your server already made. Identity is yours; irtio’s job is to make the identity you assert durable and confineable inside a room.
Next steps
- Player storage for what
ctx.playerIdkeys, and the cost of adopting JWT late. - Visibility for what a role gets to see.
- CLI reference for
keys jwt-secretandkeys jwt-mintin full. - Error catalogue for every code above.