Skip to content
Hi! I am Gab

All notes

Oct 2026

Login Worked Everywhere Except iPhone: Third-Party Cookies, Safari, and a Next.js Proxy

I build a sales and inventory system for a family pharmacy: a Next.js frontend on Vercel and a Django REST API on Render. One day, logging in on an iPhone sent users straight back to the login page with no error. The bug wasn't in either server. It was in the browser.

The setup

Browser ──▶ app.vercel.app (Next.js) ──▶ api.onrender.com (Django, session auth)

Django uses session cookies. The frontend calls the API directly from the browser, so the sessionid cookie is set by onrender.com while the user is on vercel.app.

Why that's a problem: "site" vs "origin"

Browsers decide cookie behavior by site, the registrable domain (eTLD+1), not by origin. vercel.app and onrender.com are both on the Public Suffix List, so each deployment is its own site, and these two are different sites. Any cookie the API sets is a third-party cookie from the frontend's point of view.

Locally this never showed up. localhost:3000 and localhost:8000 are different origins but the same site, so cookies flowed freely.

First fix: SameSite=None; Secure

The default SameSite=Lax stopped the cookie from crossing sites, so I made cookie settings environment-driven:

SESSION_COOKIE_SAMESITE = config("SESSION_COOKIE_SAMESITE", default="Lax")   # "None" in prod
SESSION_COOKIE_SECURE   = config("SESSION_COOKIE_SECURE", default=False, cast=bool)
CSRF_COOKIE_SAMESITE    = config("CSRF_COOKIE_SAMESITE", default="Lax")
CSRF_COOKIE_SECURE      = config("CSRF_COOKIE_SECURE", default=False, cast=bool)

Desktop login worked. iPhone didn't.

The iPhone failure, step by step

iOS Safari blocks third-party cookies by default ("Prevent Cross-Site Tracking"). Other iOS browsers generally use the same WebKit engine, so they behave the same way. SameSite=None doesn't help: it gives permission to send the cookie cross-site, and Safari overrides that.

Tracing the login code explained the silent failure:

  1. POST /auth/login/ returns 200 with the user. That's why no error appeared.
  2. Safari discards the Set-Cookie.
  3. GET /auth/me/ returns 401, because no session cookie was sent.
  4. The frontend never writes its own "logged in" marker cookie.
  5. Next.js middleware sees no marker and redirects to /login?callbackUrl=.... Step 5 matched the redirect in the Vercel log exactly.

The servers showed nothing for two reasons. The login request went browser to Render directly, never through Vercel. And the Render log entry I had checked came from a desktop browser, not the phone.

I deduced this from the code path, the logged redirect, the desktop-vs-mobile split, and the confirmed SameSite=None cookie. I didn't reproduce it on the device. Turning off "Prevent Cross-Site Tracking" and retrying would confirm it.

The fix: make the cookie first-party

If the browser only ever talks to one origin, the cookie is first-party and nothing blocks it. A Next.js rewrite proxies /api/v1/* to Django on the server side:

// next.config.mjs
const backend = process.env.BACKEND_ORIGIN;  // server-only: no NEXT_PUBLIC_ prefix
 
export default {
  async rewrites() {
    return {
      beforeFiles: backend
        ? [{ source: "/api/v1/:path(.*)", destination: `${backend}/api/v1/:path` }]
        : [],
    };
  },
};

The frontend then calls relative URLs. An empty NEXT_PUBLIC_API_BASE_URL now explicitly means "same origin, use the proxy."

The proxied Set-Cookie arrives with no Domain attribute, so the browser scopes it to the Vercel origin. That makes it first-party. The backend didn't need changes, since the Vercel origin was already in CSRF_TRUSTED_ORIGINS.

The bug inside the fix: trailing slashes

My first rewrite used :path*. The build passed, and the login would have broken anyway.

Next's :path* matcher doesn't capture a trailing slash. Every Django route ends in one, so /api/v1/auth/csrf/ was proxied as /api/v1/auth/csrf. Django's APPEND_SLASH then answered with a 301. Browsers follow a redirect for a GET, but they don't replay a POST body across a 301, so login fails.

:path(.*) captures the rest of the path verbatim, slash included. I left a comment in the config so nobody "simplifies" it back.

Verifying against live staging

I ran the production build locally, pointed at the real API:

  • GET /api/v1/auth/csrf/ through the proxy returned 200, with Set-Cookie and no Domain.
  • POST /api/v1/auth/login/ with a fake user returned 400 "Invalid credentials." That meant no 301, CSRF was accepted, and the request reached Django's real credential check. A deliberately failing login is a good probe: it proves the whole path works without needing a real password.

Deployment gotcha

NEXT_PUBLIC_* variables are inlined at build time. Saving a new value in Vercel does nothing until you redeploy. BACKEND_ORIGIN has no NEXT_PUBLIC_ prefix on purpose, so the browser never learns the real API host.

Trade-offs

  • Every API call now passes through a Vercel function: one more network hop, and it counts against invocations. That's fine for staging, but worth watching in production.
  • The long-term fix is a shared parent domain (app.example.com and api.example.com). Those are the same site, so cookies work natively and the proxy can go.
  • Once the proxy is confirmed working, cookies can go back to SameSite=Lax.

What I took away

  • Same site and same origin are different things, and local development hides the difference.
  • A 200 response isn't success if the next request depends on a cookie the browser threw away.
  • Server logs can't show client-side failures. Trace the client code path instead.
  • Test against the live environment. The build didn't catch the trailing-slash bug. A real request did.

Further reading