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:
AUTH_MODE=oidcOIDC_ISSUER=https://keycloak.example.com/realms/yourteamOIDC_CLIENT_ID=parleyOIDC_CLIENT_SECRET=... # omit entirely for a public-client registrationRegister <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.
Signing out
Section titled “Signing out”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.
Passcodes still apply
Section titled “Passcodes still apply”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.
Cookies
Section titled “Cookies”| Path | / | |
|---|---|---|
| HttpOnly | yes | |
| SameSite | Lax | Part of the cross-site request defence |
| Secure | when BASE_URL is https | This is why BASE_URL must be right |
| Max-Age | 90 days by default | The 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 |
| Path | /auth | Never sent to the API |
|---|---|---|
| HttpOnly | yes | |
| SameSite | Lax | |
| Secure | when BASE_URL is https | |
| Max-Age | 600s | Cleared 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.
Tokens
Section titled “Tokens”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.
How long a session actually lasts
Section titled “How long a session actually lasts”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_TTLof inactivity — 90 days unless you set it — and every write, plus every WebSocket connect, pushes that window out. AGETdoes not: the cross-site guard wavesGETthrough, so a refreshingGETwould 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-Agefrom 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.
Revocation and long-lived connections
Section titled “Revocation and long-lived connections”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 out | supported | Deletes the token, closes its WebSockets on every replica, and clears the cookie |
|---|---|---|
| Rename | rotates the token | In one transaction |
| Sign out everywhere UI | not supported | Delete the user's rows from session_tokens in SQL; shared-store detection and removal can take nearly 60s, then transport closure follows |
| Administrative revocation UI | not supported | A 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 cleanup | hourly | Deletes 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.
Calendar feed token
Section titled “Calendar feed token”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.
Embedded sessions
Section titled “Embedded sessions”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:
- 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 anawaitis 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. /embed/signinis 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 theLaxcookie. Link guests and embedded sessions cannot bind.- The frame polls
POST /api/embed/sessionwith the verifier. Until the handoff is bound it gets202andRetry-After; once bound it receives a session token exactly once. A wrong verifier, an unknown, late or spent handoff is404.
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.
What the handoff does not prevent
Section titled “What the handoff does not prevent”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.