Authentication

irtio accepts two kinds of credential, and every project starts with the first one.

credentialwhat it provesctx.playerId
project key (default)this page is allowed to talk to this projectthe client id, stable only as long as the resume token
project key + JWTand 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"
}
claimrequiredchecked against
issyesmust be an issuer the project has a secret for; ^[a-z0-9._-]{1,64}$, never a colon
audyesmust equal the project id
subyes1 to 128 printable ASCII characters, no spaces; becomes the second half of playerId
expyesunix seconds; rejected once past, with 60 s of leeway
iatnorejected if more than 60 s in the future
roomIdnoif present, must equal the room actually joined
rolenooverrides 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 seeit means
E_AUTHthe project key is wrong, or you sent a token to a project with no signing secret
E_TOKEN_MALFORMEDnot three dot-separated segments, or a claim is missing or the wrong type
E_TOKEN_BAD_ISSUERiss names an issuer this project has no secret for
E_TOKEN_BAD_ALGthe header’s alg is not the issuer’s configured algorithm
E_TOKEN_INVALIDthe signature does not verify under any active secret
E_TOKEN_EXPIREDexp has passed, or iat is in the future, beyond 60 s
E_TOKEN_WRONG_PROJECTaud names a different project
E_TOKEN_WRONG_ROOMroomId 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