Skip to content

Authentication

Verified against v0.10.0 · internal/auth/oidc.go, internal/api/auth.go, internal/api/principal.go, internal/api/me.go, internal/store/users.go, internal/hub/hub.go, internal/api/ics.go, internal/standup/ics.go, internal/api/embed.go, internal/api/router.go, internal/api/authz.go, internal/store/embed.go, internal/api/spaces.go

Parley runs in one of two modes, set by AUTH_MODE and fixed at boot.

The default, and the original. No accounts: people type a name, get an avatar, and take a seat. Nothing to administer, nothing to provision. A stranger with the link and the passcode is a participant.

Use open mode only on a trusted network. A public deployment needs a passcode or an external SSO/authentication proxy in front of the whole instance, plus request and connection abuse controls at the ingress. The built-in creation limits are a backstop, not a public-internet authentication boundary.

Sign-in goes to your identity provider. There is no vendor-specific code in Parley — it is a plain OpenID Connect relying party that reads the issuer’s discovery document, so anything speaking OIDC works and switching providers is a change of configuration:

Terminal window
AUTH_MODE=oidc
OIDC_ISSUER=https://keycloak.example.com/realms/yourteam
OIDC_CLIENT_ID=parley
OIDC_CLIENT_SECRET=... # omit entirely for a public-client registration

Register <BASE_URL>/auth/callback as the redirect URI with your provider, and allow the openid, profile and email scopes.

Sign-in uses the authorization code flow with PKCE. The ID token’s signature, audience, expiry and nonce are all verified before an account is touched.

Taken from the provider’s claims — name, then preferred_username, then the local part of email, then the subject, and finally the literal “Someone” if a provider supplies none of them. Refreshed on every sign-in, so a rename upstream follows the person onto the roster. Trimmed to 64 characters.

Ends the Parley session only. It does not sign anyone out of the identity provider, which matters on a shared machine. Logout deletes the shared-store token and synchronously closes every WebSocket authenticated by that token with policy code 1008 before the request completes. Across a multi-replica deployment the disconnect is immediate on every replica, not only the one that served the request: after the token row is deleted, the replica publishes the revocation to its peers over the same Postgres LISTEN/NOTIFY channel set the rest of the live updates use, and each peer closes its own sockets for that token. The periodic revalidation below is the fallback if that notification is lost — for example while a replica’s listener is reconnecting.

Identity says who you are. The passcode says which room you may enter. Signing in does not by itself get anyone into a space — per-user and per-team access is on the roadmap.

An invite link carries its passcode in the URL fragment, which never reaches a server, but a fragment also does not survive the full-page trip to an identity provider and back. So under a provider the browser trades the code at POST /api/orgs/{org}/spaces/{slug}/invite for an invite handle and carries that across the round trip instead: an opaque token the server issues only to a caller who already presented the right passcode, stored as a sha256 digest the way guest-link tokens are, bound to one space, expiring in five minutes, and consumed by the first join that spends it. The passcode itself is never written to browser storage. The mint route is a passcode attempt like any other and spends from the same per-address budget, so it cannot be used to guess a code any faster than the join door can.

parley_session — the identity cookie
Path/
HttpOnlyyes
SameSiteLaxPart of the cross-site request defence
Securewhen BASE_URL is httpsThis is why BASE_URL must be right
Max-Age90 days by defaultThe smaller of SESSION_IDLE_TTL and SESSION_MAX_TTL, fixed from issue — see below. A guest-link session gets no Max-Age at all: see below
parley_signin — the sign-in flow cookie, oidc mode only
Path/authNever sent to the API
HttpOnlyyes
SameSiteLax
Securewhen BASE_URL is https
Max-Age600sCleared before the redirect that spends it

The flow cookie holds the OAuth state, the nonce, the PKCE verifier and the page you were heading to. It is base64 JSON, not signed or encrypted — worth knowing, and survivable, because nothing in it is trusted on the way back: state and nonce are compared in constant time against the provider’s response, and the redirect target is re-validated after the round trip rather than only when it was written.

The value in parley_session is 32 bytes of cryptographic randomness, base64url-encoded. The server stores only its SHA-256 hash, so a database dump does not yield working credentials.

The user id is deliberately not in the cookie. It is public — every participant sees it — so a cookie containing it would be trivially forgeable no matter how it was signed.

Two different clocks, and they do not agree. This catches people out, so it is worth stating precisely:

  • Server side, a token expires after SESSION_IDLE_TTL of inactivity — 90 days unless you set it — and every write, plus every WebSocket connect, pushes that window out. A GET does not: the cross-site guard waves GET through, so a refreshing GET would let any third-party page hold a victim’s session open forever. Reading, and only reading, does not count as activity.
  • Also server side, SESSION_MAX_TTL — again 90 days by default — is an absolute cap measured from the moment the token was issued. No amount of activity extends it, so a token that leaked and is being used constantly still stops working. Rows past either lifetime are deleted by an hourly background pass rather than left to accumulate.
  • Browser side, the cookie is written with a fixed Max-Age from the moment it was issued — the smaller of the two lifetimes — and it is never rewritten on subsequent requests.

Both lifetimes are configured per instance; see SESSION_IDLE_TTL and SESSION_MAX_TTL. A CUI deployment typically wants idle in hours and the absolute cap in days.

A guest link session is the deliberate exception. Its cookie carries no Max-Age and no Expires, so it is a session cookie: the browser drops it when the browsing session ends. That keeps the seat off disk, which is the point of a credential aimed at a contractor or stakeholder on a borrowed machine, and the token behind it still carries the link’s own 24-hour expiry, so the seat also ends when the link does.

A browsing session is the browser, not the tab. Closing the room tab does not end the seat while another tab or window of the same browser is open — the HttpOnly cookie is untouched, and the next person to open the room URL is recovered into that guest’s seat from it. The cached name and hue live in sessionStorage and do die with the tab, but they are a cache, not the identity. Quitting the browser does end the seat, unless it is set to restore its last session — “continue where you left off” restores session cookies along with the tabs.

Leave room deletes the token server-side and is proof against all of that; so is revoking the link. Neither of them is optional on a shared machine. This is recorded under known limitations.

A refresh, or navigating away and back mid-meeting, keeps the guest seated: a session cookie survives every navigation inside the browsing session. Parley deliberately does not tear the seat down on pagehide — that fires on an ordinary reload and on mobile backgrounding too, and would strand a guest who did nothing but refresh.

On the defaults the two run out together. Somebody who uses Parley every single day is still signed out 90 days after signing in, because the absolute cap elapses and the cookie expires alongside it, however healthy the activity record looks.

Under an identity provider they sign in again and carry on; nothing is lost. In open mode there is nothing to sign back into: taking a seat again mints a new anonymous identity. The previous memberships stay with the orphaned user. The app surfaces that as an explicit “Your session ended” gate rather than as a silent first-visit name prompt, but it cannot restore the old seat — the cookie was the whole of it. Do not tell your users that activity keeps them signed in, because it does not.

WebSockets do not trust only the state captured at upgrade time. Each connection starts a token check in Postgres every 30 seconds without extending its idle lifetime, and also has a timer for the known expiry. A check may take up to 30 seconds before it fails closed. A deleted or expired token closes the connection with WebSocket policy code 1008. Shared-store checks mean revocation works even when the database change was made outside this process; allow up to 60 seconds from an external deletion for the next check and its timeout to complete. A logout performed through Parley itself does not wait for that: it is fanned out to every replica immediately, and the periodic check is only the backstop.

Sign outsupportedDeletes the token, closes its WebSockets on every replica, and clears the cookie
Renamerotates the tokenIn one transaction
Sign out everywhere UInot supportedDelete the user's rows from session_tokens in SQL; shared-store detection and removal can take nearly 60s, then transport closure follows
Administrative revocation UInot supportedA space owner can remove someone from that space, which closes their sockets for it — but it revokes no token and reaches no other space. Instance-wide revocation is SQL; shared-store detection and removal can take nearly 60s
Expired token cleanuphourlyDeletes rows past either lifetime

If you need to force everybody out of an instance right now, delete every row in session_tokens. Everyone signs in again; nothing else is affected.

A personal standup calendar is a second credential, not a session cookie. POST /api/me/ics mints it and returns the URL once. What is stored is the sha256 of the token, plus the first eight characters as a lookup prefix so a fetch can find the row. The plaintext is not kept, so it cannot be shown again.

GET /ics/{token} does not use a cookie and does not sit under /api, so it has no cross-site cookie to defend. Calendar clients fetch it as a plain GET. A missing or revoked token is 404, never 401 or 403 — the status does not say whether that string was ever issued. Space membership is read on every fetch, not at mint time: a space the person has left is absent from the next response.

The token is redacted to /ics/[redacted] before anything in this process logs the request path, including security-event lines. A reverse proxy in front of Parley still sees the original URL; mask /ics/ in that access log if you keep one. Revoking it with DELETE /api/me/ics (the profile control for this is not built yet), revoking the person’s org membership, or deleting the user all make the URL stop resolving. Org-membership revoke and the feed revocation are one transaction. User delete removes the row by foreign key.

Off unless EMBED_PROVIDERS names a meeting client (today only meet, Google Meet). With it empty every /embed/* and /api/embed/* route answers 404 and no Authorization header is ever read, so an air-gapped install never references a third-party script.

The embed is a new door, never a new key: someone who could already enter a room can enter it from a meeting’s side panel. Membership, passcodes, link-guest limits and redaction are unchanged.

A meeting client frames Parley’s own origin but withholds its cookies, so the frame signs in by handoff, shaped like the OAuth device flow:

  1. The frame makes a random RFC 7636 verifier (43–128 characters) and sends only its SHA-256 challenge to POST /api/embed/handoff. The server opens a five-minute handoff, throttled per client address, and returns a short display code for the frame to show. The throttle is keyed on the client address, so people behind one NAT or proxy share its budget. A frame should do this when its panel loads, not on click: a popup opened after an await is blocked, so the Sign in click must do nothing but open /embed/signin?c=<challenge>. That URL and the display code can also be shown for opening by hand.
  2. /embed/signin is an ordinary top-level Parley page (X-Frame-Options: DENY). It needs the person signed in to Parley in that browser, in either mode, and names the meeting client only from an enabled provider. It does not show the display code: it asks the person to type the code their meeting shows, and binds the handoff only if it matches. The comparison is server-side and constant-time, ignores case, the hyphen and surrounding spaces, and every attempt spends from the same per-client budget as a space passcode (eight a minute); a right code gets its attempt back. Its form post is covered by the cross-site check and the Lax cookie. Link guests and embedded sessions cannot bind.
  3. The frame polls POST /api/embed/session with the verifier. Until the handoff is bound it gets 202 and Retry-After; once bound it receives a session token exactly once. A wrong verifier, an unknown, late or spent handoff is 404.

The token is an ordinary session_tokens row marked embedded, with an absolute expiry 12 hours after it was minted; the idle and maximum lifetimes above still apply, and revocation and socket revalidation work as for any session. The frame sends it as Authorization: Bearer on /api and as the second value of Sec-WebSocket-Protocol: parley.embed, <token> on /ws (the server only ever echoes parley.embed; a ?token= query is refused). Only those two mounts read a bearer. When an Authorization header is present the cookie is not read, and a bearer that is not an embedded token resolves to nobody. Signing out follows the same rule: DELETE /api/me with an Authorization header that does not resolve signs nobody out, whatever cookie rides along.

With embedding on, this applies to any Authorization header on /api and /ws, not only Bearer. A forward-auth or basic-auth proxy in front of Parley that injects its own Authorization header therefore replaces every cookie user’s session with nobody, and sign-in appears to work but never sticks. Strip the header at the proxy before it reaches Parley, or leave EMBED_PROVIDERS empty on such an instance.

An embedded session has participant power only, enforced as an allow-list in one place on the /api mount. It may reach these routes and nothing else:

Route Why the side panel needs it
GET /api/auth, POST /api/embed/handoff, POST /api/embed/session The handoff itself; none of them take a principal.
GET /api/me, DELETE /api/me Who am I, and signing out, which deletes the embedded token.
GET /api/orgs, GET /api/spaces, GET /api/orgs/{org}/spaces, GET /api/orgs/{org}/spaces/{slug} Finding a room among the spaces the person can already see.
POST /api/orgs/{org}/spaces/{slug}/join Joining a space with its passcode.
GET /api/sessions/{id}, GET /api/sessions/{id}/plugins/panels, /api/sessions/{id}/actions/{action} Reading a room and taking part in it: the same set a link guest is given.
GET /api/sessions/{id}/mentions A standup’s “needs you”: who asked this person for help and whom they asked. It is read per caller, not off the broadcast, so a room cannot show its own asks without it. A link guest is refused it, having no asks to read.
GET /ws The room’s live updates.

Every other /api route answers 403 to an embedded session, whoever holds it and whatever their roles: creating or deleting spaces and rooms, rotating a passcode, changing visibility, member roles or removals, decks, kudos, standup schedules, webhooks and the participation trend, the room-level facilitator controls — claiming or transferring the facilitator seat, removing a participant, and closing or reopening the room — the CSV export, signed links (minting, listing or redeeming), renaming, avatar and settings, away days, the calendar feed, and all of org administration. Each allowed route’s own checks still apply, so an embedded session never gets more than the same person’s cookie. Org administration is refused a second time inside the admin gate. Renaming rotates the session token, and the rotated row keeps the embedded flag and the original expiry, so a rename could never widen a session even if it were allowed.

The refusal above is on route-level controls, not on facilitating inside a room: /api/sessions/{id}/actions/{action} is on the allow-list, so an embedded facilitator keeps every in-room kind action the same person’s cookie would have — poker’s reveal, reset and config, standup’s start, next and skip. GET /api/orgs/{org}/spaces/{slug}’s member view is also on the allow-list but withholds one field from an embedded session that a cookie gets: the space’s passcode, so a phished embedded session (the residual risk below) cannot harvest every passcode its holder’s spaces have.

The handoff has the device flow’s residual risk. Typing the code proves that the person copied it from a meeting panel, not that the panel belongs to them. An attacker who opens a handoff in their own panel and talks a signed-in person into visiting the link and typing the attacker’s code (“read me the code on this screen”, “type ABC-123 to join”) gets an embedded session as that person. That session is limited to the allow-list above and to the person’s own memberships, lasts at most 12 hours, and ends with DELETE /api/me or any revocation of that person’s sessions. Never type a code you did not see in your own meeting.