Skip to content

ADR-25: OAuth Return URL Handling

🇰🇷 한국어 버전

DateAuthorRepos
2026-01-16@specvitalweb

Context

Users logging in via GitHub OAuth from pages other than the homepage were redirected to the dashboard instead of returning to their original page.

Entry PointExpected BehaviorActual Behavior
/en/pricingReturn to /en/pricingRedirect to /en/dashboard
/en/exploreReturn to /en/exploreRedirect to /en/dashboard
/en/{owner}/{repo}Return to analysis pageRedirect to /en/dashboard

Root Causes

1. Conflicting Redirect Logic

The AuthenticatedRedirect component on the homepage affected redirect behavior globally.

2. Client-Server Storage Mismatch

Client (Browser)                    Server (Route Handler)
┌─────────────────┐                ┌──────────────────────┐
│ sessionStorage  │ ─────────X──── │ OAuth callback       │
│ (returnTo URL)  │  Not accessible│ (code exchange)      │
└─────────────────┘                └──────────────────────┘

The initial approach stored returnTo in sessionStorage, but Next.js App Router OAuth callbacks execute as server-side Route Handlers, which cannot access browser sessionStorage.

Constraints

ConstraintSourceImplication
Server-side OAuth callbackNext.js App RouterCannot use client-only storage
Locale preservationADR-17Return URL must include locale prefix
Security requirementsOAuth best practicesMust prevent open redirect attacks

Decision

Cookie-based return URL storage with server-side redirect validation.

typescript
// Before OAuth redirect (client-side)
const returnTo = window.location.pathname;
document.cookie = `returnTo=${encodeURIComponent(returnTo)}; path=/; max-age=300; SameSite=Lax`;
typescript
// OAuth callback (server-side Route Handler)
const returnTo = cookies().get("returnTo")?.value;
const safeReturnTo = validateReturnUrl(returnTo);
cookies().delete("returnTo");
redirect(safeReturnTo || "/dashboard");
ParameterValueRationale
StorageHTTP CookieAccessible server-side
Max-Age300s (5 min)Covers OAuth flow, limits stale URL risk
SameSiteLaxPrevents cross-site request attacks
Path/Available to all routes

Options Considered

Option A: sessionStorage-based (Initial, Failed)

  • Store returnTo in browser sessionStorage

Pros:

  • Simple, no cookie management
  • Scoped to browser tab

Cons:

  • Server-side Route Handler cannot access sessionStorage
  • Architectural incompatibility with Next.js App Router

Decision: Rejected.

  • Store returnTo in HTTP cookie before OAuth redirect
  • Server reads, validates, and clears cookie on callback

Pros:

  • Works with server-side Route Handlers
  • Short-lived (5 min)
  • SameSite=Lax provides CSRF protection

Cons:

  • Requires cookie management
  • Open redirect vector (mitigated by validation)

Decision: Selected.

Option C: OAuth State Parameter

  • Encode returnTo within OAuth state parameter

Pros:

  • Stateless, built into OAuth spec

Cons:

  • GitHub state parameter size limits
  • Complicates state handling if used for CSRF protection

Decision: Rejected.

Option D: Database Session Storage

  • Store returnTo in session table

Pros:

  • Reliable server-side storage

Cons:

  • Adds database latency
  • Over-engineered for redirect URL storage
  • Violates PaaS-first simplicity (ADR-06)

Decision: Rejected.

Implementation

Return URL Flow

1. User on /en/pricing clicks "Sign in with GitHub"
   └─> Set cookie: returnTo=/en/pricing; max-age=300

2. Redirect to GitHub OAuth
   └─> User authenticates on github.com

3. GitHub redirects to /api/auth/callback/github
   └─> Server reads returnTo cookie
   └─> Validate: starts with "/" and not "//"
   └─> Delete cookie
   └─> Redirect to /en/pricing

4. User arrives back at /en/pricing (authenticated)

URL Validation Logic

typescript
function validateReturnUrl(url: string | undefined): string | null {
  if (!url) return null;

  // Must start with single slash (relative path)
  if (!url.startsWith("/")) return null;

  // Reject protocol-relative URLs (open redirect vector)
  if (url.startsWith("//")) return null;

  // Reject URLs with embedded credentials
  if (url.includes("@") || url.includes("\\")) return null;

  return url;
}

Consequences

Positive:

  • Users return to original page after OAuth login
  • Locale/i18n context preserved
  • Works with Next.js App Router server-side handlers
  • Short cookie expiry (5 min) limits attack window

Negative:

  • Cookie overhead (minimal, <100 bytes)
  • Open redirect attack surface (mitigated by validation)
  • Falls back to dashboard if cookies disabled

Security Considerations

Open Redirect Prevention

Attack VectorMitigation
Absolute URL injectionRequire / prefix
Protocol-relative URLReject // prefix
URL with credentialsReject @ character
Backslash bypassReject \ character
AttributeValueBenefit
SameSiteLaxPrevents cross-site attacks
Max-Age300Limits stale URL risk
Path/Application-scoped

References

Open-source test coverage insights