audio streaming app plyr.fm

docs: add redirect-after-login UX research (#1000)

Research on preserving deep links through auth flows, covering
approaches from GitHub, Discord, Slack, and others. Recommends
combining query parameter + cookie + OAuth state for ATProto.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
cd2e7b78 d049947e

+443
+443
docs/research/2026-02-28-redirect-after-login-ux.md
··· 1 + # redirect after login (deep link preservation through auth) 2 + 3 + **status:** research complete 4 + **date:** 2026-02-28 5 + 6 + ## problem 7 + 8 + when an unauthenticated user follows a deep link (e.g. a jam invite, a shared track, a profile page), they need to log in before they can interact. after login, they should land on the page they originally intended to visit — not a generic dashboard. 9 + 10 + this is the "redirect after login" or "return URL" pattern. getting it wrong means: 11 + - users lose context and have to re-find the link 12 + - invite/share flows feel broken 13 + - new user onboarding from shared content is frustrating 14 + 15 + for plyr.fm specifically: someone shares a jam link, the recipient clicks it, sees "log in to join this jam," authenticates via ATProto OAuth (which bounces through a third-party PDS), and should land directly in the jam — not on `/`. 16 + 17 + ## how popular products handle this 18 + 19 + ### GitHub 20 + 21 + GitHub uses a `return_to` query parameter. when you visit a protected page unauthenticated (e.g. a private repo settings page), the login page URL becomes `/login?return_to=%2Fsettings%2F...`. after successful auth, GitHub redirects to the stored `return_to` value. the parameter is URL-encoded and validated server-side. GitHub also preserves `return_to` through their OAuth flow by encoding context in the OAuth `state` parameter. 22 + 23 + ### Discord 24 + 25 + Discord handles invite links (`discord.gg/xxxxx`) by showing the server preview (name, member count, description) even to unauthenticated users. if the user clicks "Accept Invite," they're prompted to log in or create an account. Discord preserves the invite context through the auth flow — after login/signup, the user is automatically joined to the server. the invite metadata is stored server-side (keyed by invite code), so there's no URL parameter to lose. for users who already have the app installed, invite links trigger a deep link to the native app. 26 + 27 + ### Slack 28 + 29 + Slack invite links show workspace information without requiring auth. clicking "Join" prompts email verification (magic link flow). the invite context is preserved via the invite token in the URL, which maps to server-side state. after creating an account or logging in, the user is added to the workspace. notably, Slack uses email verification rather than OAuth redirect, which avoids the multi-step redirect problem entirely. 30 + 31 + ### Google Docs / Drive 32 + 33 + Google shows a "you need access" interstitial when an unauthenticated user visits a restricted document. after login, the user lands on the document if they have permission, or sees a "request access" page if they don't. Google preserves the document URL through their auth flow using the `continue` parameter. for public documents ("anyone with link"), no auth is needed at all. 34 + 35 + ### Figma 36 + 37 + Figma's sharing behavior depends on plan tier and share settings. "anyone with link" on professional plans doesn't require login. on starter plans, viewers must be invited and logged in. Figma redirects to `/email_only` for auth, preserving the file URL. this is a common source of user complaints — the behavior is described as "unintuitive and needlessly complicated" because users can't always tell when login will be required. 38 + 39 + ### Notion 40 + 41 + Notion distinguishes between "publish to web" (truly public, no login) and "share with link" (requires Notion account). published pages get a different URL format. shared pages require login and preserve the page URL through the auth flow via query parameter. 42 + 43 + ### Linear 44 + 45 + Linear uses OAuth 2.0 with a `state` parameter that preserves context through redirects. invite links redirect to login with the team/workspace context preserved. if the user has previously approved access, Linear auto-redirects without showing the consent screen again. 46 + 47 + ### Spotify 48 + 49 + Spotify share links (open.spotify.com) work without auth for basic content display. interacting (play, save, follow) triggers auth. the target content is encoded in the URL structure itself (`/track/xxx`, `/playlist/xxx`), so no separate return URL is needed — after auth, the user is on the same page. there are known issues with Safari on iOS where the post-auth redirect fails and users get stuck on the auth page. 50 + 51 + ## technical approaches 52 + 53 + ### approach 1: query parameter (most common) 54 + 55 + store the return URL as a query parameter on the login page URL. 56 + 57 + | framework | parameter name | example | 58 + |-----------|---------------|---------| 59 + | Django | `next` | `/login?next=/dashboard/lesson/42` | 60 + | Rails (Devise) | `redirect_to` | `/users/sign_in?redirect_to=/settings` | 61 + | Next.js (NextAuth) | `callbackUrl` | `/api/auth/signin?callbackUrl=/protected` | 62 + | GitHub | `return_to` | `/login?return_to=%2Frepo%2Fsettings` | 63 + | Auth0 | `redirect` | configurable in rules | 64 + | Discourse | `return_path` | `/login?return_path=/topic/123` | 65 + 66 + **naming conventions seen in the wild:** 67 + - `next` — Django, many Python frameworks 68 + - `return_to` — GitHub, Ruby ecosystems 69 + - `redirect_to` — Rails, general web 70 + - `redirect` — generic 71 + - `callbackUrl` — NextAuth.js, Next.js ecosystem 72 + - `continue` — Google 73 + - `redirect_uri` — OAuth 2.0 (different purpose: callback URL, not return URL) 74 + 75 + **pros:** simple, stateless, works across browser restarts 76 + **cons:** URL can get very long, exposed in browser history and referrer headers, must be validated to prevent open redirect 77 + 78 + ### approach 2: cookie/session storage 79 + 80 + store the return URL in a session cookie before redirecting to login. 81 + 82 + ``` 83 + 1. user visits /jam/abc123 84 + 2. server sets cookie: plyr_return_to=/jam/abc123 85 + 3. server redirects to /login 86 + 4. user authenticates 87 + 5. server reads cookie, redirects to /jam/abc123 88 + 6. server clears cookie 89 + ``` 90 + 91 + **pros:** return URL not visible in browser bar, no length limits, survives page reloads 92 + **cons:** cookies can expire or be cleared, doesn't survive cross-device flows, adds server-side state 93 + 94 + ### approach 3: OAuth `state` parameter 95 + 96 + encode the return URL (or a reference to it) in the OAuth `state` parameter. the `state` value survives the entire OAuth redirect chain and is returned to the callback URL. 97 + 98 + ``` 99 + 1. user visits /jam/abc123 (unauthenticated) 100 + 2. app stores return_to=/jam/abc123 in session 101 + 3. app generates state=<random> and maps state -> session 102 + 4. user is redirected to PDS authorization server with state=<random> 103 + 5. user authenticates at PDS 104 + 6. PDS redirects to app callback with state=<random>&code=xxx 105 + 7. app looks up session from state, finds return_to=/jam/abc123 106 + 8. app completes token exchange, redirects to /jam/abc123 107 + ``` 108 + 109 + **pros:** works with multi-step OAuth, survives third-party redirects, CSRF protection built in 110 + **cons:** more complex to implement, state must be stored server-side (or encrypted/signed if embedded) 111 + 112 + ### approach 4: localStorage (avoid for auth-critical flows) 113 + 114 + store the return URL in `localStorage` before redirecting to login. 115 + 116 + **pros:** simple to implement, persists across tabs 117 + **cons:** XSS-vulnerable, not accessible server-side, breaks HttpOnly security model, cleared by "clear site data" 118 + 119 + ### recommended approach for plyr.fm 120 + 121 + **combine query parameter + cookie + OAuth state** — this is the belt-and-suspenders approach that handles ATProto's multi-step OAuth flow: 122 + 123 + 1. when an unauthenticated user visits a protected page, redirect to `/login?return_to=/jam/abc123` 124 + 2. the login page stores `return_to` in an HttpOnly cookie (short-lived, 10 minutes) 125 + 3. when OAuth starts, the `return_to` value is associated with the OAuth session (via the `state` parameter mapping) 126 + 4. after OAuth completes and the callback fires, read the `return_to` from the session/cookie 127 + 5. redirect to `return_to` (with validation) or fall back to `/` 128 + 6. clear the cookie 129 + 130 + the query parameter provides visibility ("you can see where you'll go after login"), the cookie provides persistence (survives the OAuth bounce through the PDS), and the state mapping provides security (ties the return URL to the specific auth session). 131 + 132 + ## security considerations 133 + 134 + ### open redirect vulnerability 135 + 136 + the biggest risk with redirect-after-login is **open redirect**: an attacker crafts a URL like `/login?return_to=https://evil.com/phishing` and the app blindly redirects there after login, lending credibility to the phishing site because the user just authenticated on the real site. 137 + 138 + ### validation strategies (from OWASP) 139 + 140 + **1. allow relative paths only (recommended for most apps)** 141 + 142 + ```python 143 + from urllib.parse import urlparse 144 + 145 + def validate_return_url(url: str) -> str | None: 146 + """return the URL if it's a safe relative path, None otherwise.""" 147 + if not url: 148 + return None 149 + # must start with / but not // 150 + if not url.startswith("/") or url.startswith("//"): 151 + return None 152 + # parse and verify no scheme or netloc 153 + parsed = urlparse(url) 154 + if parsed.scheme or parsed.netloc: 155 + return None 156 + return url 157 + ``` 158 + 159 + **2. allowlist of paths or prefixes** 160 + 161 + ```python 162 + ALLOWED_PREFIXES = ["/jam/", "/track/", "/profile/", "/settings"] 163 + 164 + def validate_return_url(url: str) -> str | None: 165 + url = validate_relative_url(url) 166 + if url and any(url.startswith(p) for p in ALLOWED_PREFIXES): 167 + return url 168 + return None 169 + ``` 170 + 171 + **3. never do this — common bypass techniques:** 172 + 173 + | bypass | why it works | 174 + |--------|-------------| 175 + | `//evil.com` | protocol-relative URL, browser navigates to `evil.com` | 176 + | `/\evil.com` | some parsers treat `\` as `/`, creating `//evil.com` | 177 + | `javascript:alert(1)` | scheme injection | 178 + | `https://trusted.com@evil.com` | userinfo abuse — browser goes to `evil.com` | 179 + | `data:text/html,...` | data URI scheme | 180 + | URL-encoded variants | `%2F%2Fevil.com` bypasses string checks but is decoded by browser | 181 + 182 + **4. never use blocklists** — there are always new bypass techniques. always use allowlists (permit known-good patterns, reject everything else). 183 + 184 + **5. server-side token mapping (most secure)** 185 + 186 + instead of passing the URL as a parameter, map it to a short-lived token: 187 + 188 + ```python 189 + # when creating the redirect 190 + token = secrets.token_urlsafe(16) 191 + await cache.set(f"return_to:{token}", "/jam/abc123", ttl=600) 192 + # redirect to /login?return_token=xyz 193 + 194 + # after login 195 + url = await cache.get(f"return_to:{token}") 196 + await cache.delete(f"return_to:{token}") 197 + # redirect to url or / 198 + ``` 199 + 200 + this eliminates open redirect entirely — the URL never appears in the query string. 201 + 202 + ## edge cases and gotchas 203 + 204 + ### long redirect URLs 205 + 206 + URLs with query parameters and fragments can exceed browser/server limits. browser limits vary (IE had 2083 chars, modern browsers support much more) but intermediate proxies, CDNs, and OAuth providers may truncate. Microsoft's identity platform limits redirect URIs to 256 characters. 207 + 208 + **mitigations:** 209 + - store the full URL server-side and pass only a token/key in the query parameter 210 + - strip unnecessary query parameters before storing 211 + - set a maximum length and fall back to `/` if exceeded 212 + 213 + ### user creates a new account instead of logging in 214 + 215 + the return URL must survive the login-vs-signup decision. if the login page offers both options: 216 + 217 + - if `return_to` is a query parameter, it must be passed to the signup form/page too 218 + - if stored in a cookie, it naturally survives the switch between login and signup 219 + - if stored in OAuth state, it survives regardless of whether the user logs in or creates a new account at the PDS 220 + 221 + **key insight:** most OAuth providers (including ATProto PDS) handle both login and registration on their end, so the return URL only needs to survive on the client app side, not at the auth provider. 222 + 223 + ### multi-step OAuth (ATProto-specific) 224 + 225 + ATProto OAuth is particularly challenging because: 226 + 227 + 1. the user enters their handle on the app 228 + 2. the app resolves the handle to a DID, then to a PDS, then to an authorization server 229 + 3. this resolution involves 5+ API calls 230 + 4. the user is redirected to the authorization server (which may be a different domain than the PDS) 231 + 5. the user authenticates there 232 + 6. the authorization server redirects back to the app's callback URL with a `code` and `state` 233 + 7. the app exchanges the code for tokens 234 + 235 + the return URL must survive this entire chain. the `state` parameter is the only reliable mechanism — it's passed to the authorization server and returned unchanged. store the return URL in server-side session keyed by the `state` value. 236 + 237 + ``` 238 + user clicks /jam/abc123 239 + → app stores {state: "xyz", return_to: "/jam/abc123"} in session/redis 240 + → app redirects to PDS auth server with state=xyz 241 + → user authenticates at PDS 242 + → PDS redirects to /auth/callback?code=abc&state=xyz 243 + → app looks up state=xyz → return_to="/jam/abc123" 244 + → app redirects to /jam/abc123 245 + ``` 246 + 247 + ### redirect target requires permissions the user doesn't have 248 + 249 + after login, the user might not have the right to access the target resource. distinguish between: 250 + 251 + | scenario | response | 252 + |----------|----------| 253 + | unauthenticated | redirect to login with return URL | 254 + | authenticated, has access | show the page | 255 + | authenticated, no access | show 403 page with context ("this jam is private" / "request access") | 256 + | authenticated, resource doesn't exist | show 404 | 257 + 258 + never redirect back to login if the user is already authenticated but lacks permissions — this creates an infinite redirect loop. Django had [a bug like this](https://code.djangoproject.com/ticket/28379). 259 + 260 + ### OAuth callback URL fragment loss 261 + 262 + URL fragments (the `#` part) are not sent to servers by browsers. if the original URL was `/jam/abc123#chat`, the `#chat` part is lost when the server stores the return URL. this is usually acceptable — fragments are for client-side state and the page can reconstruct them. 263 + 264 + ### race conditions with multiple tabs 265 + 266 + if a user opens two protected links in different tabs, each tab might try to set the same cookie or session key with different return URLs. the last one wins. this is generally acceptable — the user will land on one of the two pages and can navigate to the other. 267 + 268 + ### expired or consumed return URLs 269 + 270 + return URL tokens/cookies should be short-lived (10 minutes max) and single-use. after redirect, delete the stored value. this prevents replay and stale redirects. 271 + 272 + ## UX best practices 273 + 274 + ### 1. tell the user why they need to log in 275 + 276 + bad: generic "please log in" page 277 + good: contextual message based on what they were trying to do 278 + 279 + | context | message | 280 + |---------|---------| 281 + | jam invite | "log in to join this jam" | 282 + | shared track | "log in to listen to this track" | 283 + | profile page | "log in to follow @artist" | 284 + | settings | "log in to access your settings" | 285 + | generic | "log in to continue" | 286 + 287 + the login page should receive enough context to display this. either: 288 + - pass a `context` parameter alongside `return_to` (e.g. `?return_to=/jam/abc&context=jam_invite`) 289 + - derive the context from the return URL path (if `/jam/` then it's a jam invite) 290 + 291 + ### 2. show a preview of what's behind the login 292 + 293 + Discord does this well — you see the server name, icon, member count, and description before being asked to log in. this motivates the user to complete the auth flow. 294 + 295 + for plyr.fm jams: show the jam name, host, current listeners, and maybe the currently playing track — all public information that doesn't require auth. 296 + 297 + ### 3. make login-vs-signup frictionless 298 + 299 + the user arriving via a shared link might not have an account. the auth flow should handle both cases without losing the return URL: 300 + - single "continue with ATProto" button that handles both login and registration at the PDS 301 + - no separate signup page that loses the return URL context 302 + 303 + ### 4. handle the "already logged in" case 304 + 305 + if a logged-in user clicks a jam invite link, skip the login step entirely and go straight to joining the jam. don't show a login page to an already-authenticated user. 306 + 307 + ### 5. provide feedback after redirect 308 + 309 + after the user logs in and lands on the target page, provide confirmation that they arrived where they intended: 310 + - for jams: "you've joined the jam!" toast 311 + - for tracks: start playing immediately 312 + - for follows: "you're now following @artist" 313 + 314 + ### 6. graceful degradation 315 + 316 + if the return URL is lost (cookie expired, state mismatch, validation failure), redirect to `/` with a subtle message: "welcome back!" rather than showing an error. the user can still navigate to what they wanted manually. 317 + 318 + ## the invite/shared link variant 319 + 320 + shared links that require auth are a special case because they often involve an **action** (join, follow, add to library) not just **viewing** a page. 321 + 322 + ### pattern: action-on-arrival 323 + 324 + ``` 325 + 1. user clicks shared link: /jam/abc123/join 326 + 2. app sees /join suffix → this is an action, not a view 327 + 3. app redirects to login with return_to=/jam/abc123/join 328 + 4. user authenticates 329 + 5. app redirects to /jam/abc123/join 330 + 6. page handler: user is now authenticated, execute join action 331 + 7. redirect to /jam/abc123 (the view) with success toast 332 + ``` 333 + 334 + the `/join` endpoint is both the redirect target AND the action handler. if the user is authenticated, it performs the action. if not, it triggers the login flow. 335 + 336 + ### pattern: token-based invites (like Discord) 337 + 338 + ``` 339 + 1. host creates jam invite → generates invite code: plyr.fm/invite/XyZ123 340 + 2. recipient clicks link 341 + 3. app looks up invite code → finds jam metadata 342 + 4. app shows preview: "join [jam name] hosted by @artist" 343 + 5. if unauthenticated: "log in to join" → auth flow preserving invite code 344 + 6. if authenticated: "join" button → POST /api/jam/join with invite code 345 + 7. after joining: redirect to /jam/abc123 346 + ``` 347 + 348 + the invite code is the state, not the URL. the code maps to server-side data (which jam, who created it, expiration, max uses). this is more robust than preserving a URL because: 349 + - the invite can expire 350 + - the invite can be single-use or limited-use 351 + - the invite can carry permissions (e.g. "join as co-host") 352 + - the invite URL is short and shareable 353 + 354 + ### comparison of shared link approaches 355 + 356 + | approach | example | pros | cons | 357 + |----------|---------|------|------| 358 + | **direct URL** | `/jam/abc123` | simple, bookmarkable | no expiration, no usage limits | 359 + | **URL + action suffix** | `/jam/abc123/join` | clear intent, works as redirect target | slightly more routing complexity | 360 + | **invite code** | `/invite/XyZ123` | expirable, usage limits, metadata | requires server-side invite storage | 361 + | **signed URL** | `/jam/abc123?token=hmac...` | no server storage, tamper-proof | long URLs, can't revoke | 362 + 363 + for plyr.fm jams, the **invite code** approach is likely best — it already aligns with the existing jam sharing model and supports features like expiration and usage limits. 364 + 365 + ## implementation sketch for plyr.fm 366 + 367 + ### backend 368 + 369 + ```python 370 + # in hooks or middleware: detect unauthenticated access to protected routes 371 + async def require_auth(request: Request) -> Response | None: 372 + if not request.state.user: 373 + return_to = request.url.path 374 + if request.url.query: 375 + return_to += f"?{request.url.query}" 376 + # validate return_to 377 + if not is_safe_return_url(return_to): 378 + return_to = "/" 379 + # store in session for OAuth flow 380 + request.session["return_to"] = return_to 381 + return RedirectResponse(f"/login?return_to={quote(return_to)}") 382 + return None 383 + 384 + # after OAuth callback completes 385 + async def handle_oauth_callback(request: Request): 386 + # ... token exchange ... 387 + return_to = request.session.pop("return_to", "/") 388 + if not is_safe_return_url(return_to): 389 + return_to = "/" 390 + return RedirectResponse(return_to) 391 + ``` 392 + 393 + ### frontend (SvelteKit) 394 + 395 + ```typescript 396 + // hooks.server.ts 397 + export const handle: Handle = async ({ event, resolve }) => { 398 + const session = await getSession(event); 399 + 400 + if (isProtectedRoute(event.url.pathname) && !session) { 401 + const returnTo = event.url.pathname + event.url.search; 402 + // store in cookie for OAuth flow persistence 403 + event.cookies.set('plyr_return_to', returnTo, { 404 + path: '/', 405 + httpOnly: true, 406 + secure: true, 407 + sameSite: 'lax', 408 + maxAge: 600 // 10 minutes 409 + }); 410 + throw redirect(303, `/login?return_to=${encodeURIComponent(returnTo)}`); 411 + } 412 + 413 + return resolve(event); 414 + }; 415 + ``` 416 + 417 + ### login page 418 + 419 + ```svelte 420 + <!-- contextual messaging based on return_to --> 421 + {#if returnTo?.startsWith('/jam/')} 422 + <p>log in to join this jam</p> 423 + {:else if returnTo?.startsWith('/track/')} 424 + <p>log in to listen</p> 425 + {:else} 426 + <p>log in to continue</p> 427 + {/if} 428 + ``` 429 + 430 + ## references 431 + 432 + - [OWASP Unvalidated Redirects and Forwards Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) — canonical security guidance 433 + - [Auth0: Redirect Users After Login](https://auth0.com/docs/authenticate/login/redirect-users-after-login) — state parameter and cookie approaches 434 + - [Auth0: State Parameters for Attack Prevention](https://auth0.com/docs/secure/attack-protection/state-parameters) — security properties of the state parameter 435 + - [ATProto OAuth spec](https://atproto.com/specs/oauth) — multi-step OAuth flow for AT Protocol 436 + - [Bluesky OAuth Client Implementation Guide](https://docs.bsky.app/docs/advanced-guides/oauth-client) — practical ATProto OAuth implementation 437 + - [PortSwigger: URL Validation Bypass Cheat Sheet](https://portswigger.net/web-security/ssrf/url-validation-bypass-cheat-sheet) — comprehensive list of URL validation bypasses 438 + - [Next.js: Preserving Deep Links After Login](https://dev.to/dalenguyen/fixing-nextjs-authentication-redirects-preserving-deep-links-after-login-pkk) — practical implementation walkthrough 439 + - [Django Login Redirect Pattern](https://django.wiki/snippets/authentication-authorization/redirect-after-login/) — canonical `?next=` implementation 440 + - [SvelteKit Hooks Documentation](https://svelte.dev/docs/kit/hooks) — handle hook for route protection 441 + - [Figma Sharing & Permissions](https://help.figma.com/hc/en-us/articles/1500007609322-Guide-to-sharing-and-permissions) — example of complex share link behavior 442 + - [Slack: Join a Workspace](https://slack.com/help/articles/212675257-Join-a-Slack-workspace) — invite link flow documentation 443 + - [Authgear: Login & Signup UX 2025 Guide](https://www.authgear.com/post/login-signup-ux-guide) — modern auth UX patterns