Back to postsEngineering

Handing authentication between separate hosts

An illustration of an authentication handoff between two separate web hosts

An illustration made for this article, rather than a screenshot of the service.

Settlement needed a separate host

Our business admin SaaS handled products, reservations, and settlement together. Settlement needed a separate host because its access and audit requirements differed. I call the app admin-app here.

  • Main app: app.example.com
  • Settlement app: finance.example.net

Company, service, domain, internal path, identifier, and token-lifetime examples have been renamed by role. Different registrable domains, the technical flow, versions, and observed counts reflect the actual system.

The hosts could differ, but the repository had to stay together. Separate apps would also mean managing shared entities, UI, and utilities separately and extracting the existing settlement code.

Instead of copying the app, I split layouts with Next.js route groups and used middleware to choose the host for each path.

text
app/
├── (auth)/login/*              # 로그인
├── (main)/                     # 메인 기능
├── finance/
│   └── (secondaryLayout)/*     # 정산: 별도 레이아웃, 같은 코드베이스
└── api/                        # API Routes (BFF)

The (secondaryLayout) route group under finance gives settlement its own layout. middleware.ts sends main-host /finance/* requests to settlement and sends other settlement-host paths back to main.

typescript
// middleware.ts (분리 8일 차 버전: 첫날에는 matcher가 없었다. 현재는 proxy.ts이며 9장에서 다시 다룬다.)
function routeToRequiredHost(request: NextRequest, domain: string | undefined) {
  if (!domain) return NextResponse.next();
  const host = domain.replace(/^https?:\/\//, '');
  if (request.headers.get('host') === host) return NextResponse.next();
  return NextResponse.redirect(new URL(request.nextUrl.pathname, domain));
}
 
export function middleware(request: NextRequest) {
  if (APP_MODE === 'local') return NextResponse.next();
  const target = request.nextUrl.pathname.startsWith('/finance')
    ? process.env.SECONDARY_ORIGIN
    : process.env.MAIN_ORIGIN;
  return routeToRequiredHost(request, target);
}
 
export const config = {
  matcher: ['/((?!api).*)'],
};

I returned to this setup during a later Next.js upgrade. A matcher redirecting static files broke the screen, and the investigation also exposed path-checking and query-preservation problems. First came the login problem immediately after splitting hosts.


The hosts cannot share cookies

Access and refresh tokens were stored in HttpOnly cookies. An Axios interceptor read the access token and added it to Authorization for authenticated requests.

typescript
// 공용 쿠키 설정
export const BASE_AUTH_COOKIE_SETTINGS: AuthCookieSettings = {
  secure: process.env.NODE_ENV === 'production',
  httpOnly: true,
};

Logging into app.example.com gives finance.example.net no cookie. Their registrable domains differ, so common company ownership does not let the browser share those cookies.

Opening settlement without another login required explicitly passing authentication from the main host. That transfer is the handoff described here.


First approach: tokens in URL parameters

The first proposal used two middleware redirects.

  1. Read cookies for main-host /finance/* and redirect to settlement with tokens in searchParams.
  2. Store those tokens in settlement cookies and redirect again to remove the parameters.
typescript
// middleware.ts (1단계: 지금은 제거된 버전)
if (request.nextUrl.pathname.startsWith('/finance')) {
  const secondaryHost = secondaryOrigin?.replace(/^https?:\/\//, '');
 
  // 1단계: 메인 호스트 → 정산 호스트, 토큰을 쿼리에 실어 보냄
  if (!secondaryHost || request.headers.get('host') !== secondaryHost) {
    const sessionToken = request.cookies.get(authCookieKey('sessionToken'))?.value;
    const renewalToken = request.cookies.get(authCookieKey('renewalToken'))?.value;
    const url = new URL(request.nextUrl.pathname, secondaryOrigin);
    if (sessionToken && renewalToken) {
      url.searchParams.set('sessionToken', sessionToken);
      url.searchParams.set('renewalToken', renewalToken);
    }
    return NextResponse.redirect(url);
  }
 
  // 2단계: 정산 호스트에서 쿼리의 토큰을 쿠키로 옮기고 깨끗한 URL로 리다이렉트
  const sessionToken = request.nextUrl.searchParams.get('sessionToken');
  const renewalToken = request.nextUrl.searchParams.get('renewalToken');
  if (sessionToken && renewalToken) {
    const sanitizedDestinationUrl = new URL(request.nextUrl.pathname, secondaryOrigin);
    const response = NextResponse.redirect(sanitizedDestinationUrl);
    response.cookies.set(authCookieKey('sessionToken'), sessionToken, AUTH_COOKIE_SETTINGS);
    response.cookies.set(authCookieKey('renewalToken'), renewalToken, AUTH_COOKIE_SETTINGS);
    return response;
  }
  return NextResponse.next();
}

Tokens disappeared from the final settlement address, but a request had already traveled through a URL containing them.

  • Reverse proxies, CDNs, and web servers could log the complete token-bearing URL.
  • Reusing the intermediate URL as a document or in another request creates further exposure to check. A server-side 3xx URL does not automatically become the next request's Referer; Fetch's redirect algorithm handles those separately.

Cleaning browser history could not clean proxy logs. I moved the tokens out of the URL.


Moving tokens into an HTML form POST

A GET redirect could not carry the body, so I created an automatically submitted HTML form. It posts tokens to settlement, where a frontend-owned server layer, the BFF, stores them as cookies.

  • Middleware returns form HTML with tokens in hidden inputs instead of redirecting.
  • The form immediately posts to settlement's /api/finance BFF.
  • The BFF reads formData, stores cookies, and redirects to the destination.

That removes tokens from query strings and ordinary URL access logs. It does not hide the request body from WAF, APM, or debug middleware. Body collection and retention settings on this route still matter.

typescript
// 토큰 수신 BFF
export async function POST(req: NextRequest) {
  const secondaryOrigin = process.env.SECONDARY_ORIGIN;
  if (!secondaryOrigin) {
    return NextResponse.json({ error: 'Secondary origin is not configured' }, { status: 500 });
  }
  const form = await req.formData();
  const sessionToken = String(form.get('sessionToken') ?? '');
  const renewalToken = String(form.get('renewalToken') ?? '');
  const destination = resolveDestination(String(form.get('destination') ?? ''));
 
  if (!sessionToken || !renewalToken) {
    return NextResponse.redirect(new URL(destination, secondaryOrigin), 303);
  }
 
  const response = NextResponse.redirect(new URL(destination, secondaryOrigin), 303);
  response.cookies.set(authCookieKey('sessionToken'), sessionToken, AUTH_COOKIE_SETTINGS);
  response.cookies.set(authCookieKey('renewalToken'), renewalToken, AUTH_COOKIE_SETTINGS);
  return response;
}

The receiving code still has a fail-open problem. Missing new tokens leave existing settlement cookies intact, allowing a previous login to continue. The request needs to fail and clear those authentication cookies when tokens are absent.

After POST, 303 See Other changes the next request to GET, avoiding form resubmission on refresh.

I transferred the refresh token too because settlement needed renewal. That also increases the impact of exposing a longer-lived credential. Sending only an access token and repeating handoff on expiry is another option, left for a decision informed by session duration.

Copying the refresh token lets both hosts renew with the same value. With rotation or reuse detection, one legitimate refresh can invalidate another session or trigger theft handling. The queue below only waits for requests in one tab. It does not coordinate refresh, logout, or revocation across hosts and tabs.


The receiver still accepted tokens from other origins

A token-receiving POST endpoint also needs to establish who submitted it. An attacker could submit their own token and leave a victim logged into the attacker's account: login CSRF.

The receiving BFF still has no Origin allowlist. The following code was a proposed change to accept only main-host submissions; it is not the applied implementation.

typescript
// Origin 검증 보강안
const mainOrigin = process.env.MAIN_ORIGIN
  ? new URL(process.env.MAIN_ORIGIN).origin
  : null;
 
export async function POST(req: NextRequest) {
  const origin = req.headers.get('origin');
  if (!mainOrigin || origin !== mainOrigin) {
    return NextResponse.json({ error: 'Forbidden origin' }, { status: 403 });
  }
  // ... 이하 토큰 처리 (2단계 코드와 동일)
}
  • Check exact Origin, rejecting missing or null values.
  • Retain form HTML Cache-Control: no-store and the secondaryOrigin early return.

Adding that check alone could reject legitimate handoffs. The form response currently uses Referrer-Policy: no-referrer, which can make Origin null for a cross-origin POST. Applying validation also means changing the policy to origin or verifying signed state or a CSRF token.

Checking Origin would not stop replaying a form. TTL, nonces, atomic one-time consumption, and session binding require shared verification state or an exchange procedure. Those are absent from this implementation.


Opening a dedicated handoff route from the menu

Middleware initially read cookies and built form HTML while also routing every request. Host selection and transferring login information were mixed together.

I moved the handoff HTML to /api/finance/bridge. The settlement menu opens that route in a new window, while middleware only redirects hosts.

  • Middleware only routes to required hosts, through a function renamed routeToRequiredHost.
  • The handoff route is the only place reading HttpOnly cookies to build form HTML.
  • The settlement menu opens the handoff endpoint in a new window rather than relying on middleware redirects.
typescript
// 정산 메뉴 클릭 핸들러
const openFinanceApp = (args: { key: string }) => {
  window.open(`/api/finance/bridge?destination=${encodeURIComponent(args.key)}`, '_blank');
};

The bridge can also be opened directly. Outside local mode, the normal UI calls it from the settlement menu. window.open still lacks noopener; explicit noopener or equivalent isolation is needed instead of relying on browser defaults.


Restricting the destination and browser behavior

The dedicated bridge also gained destination parsing and response headers. It still lacks incoming Origin validation and a nonce-based CSP.

I parse the destination with new URL rather than concatenating an untrusted value, then keep only pathname to avoid open redirects and control-character injection. Paths not starting with /finance fall back to the default settlement path. Bridge and receiver share this function.

typescript
// 정산 경로 유틸리티
export function resolveDestination(destination: string | null) {
  try {
    const { pathname } = new URL(destination ?? '', '<http://x>');
    return pathname.startsWith('/finance') ? pathname : '/finance/overview';
  } catch {
    return '/finance/overview';
  }
}

http://x supplies a base for relative-path parsing. An external URL loses its host and query. The prefix check still accepts /finance-evil, though. Restricting actual settlement paths requires checking the boundary with pathname === '/finance' || pathname.startsWith('/finance/').

The response headers restrict caching, framing, and submitting the form elsewhere.

tsx
// 핸드오프 BFF
const action = new URL('/api/finance', secondaryOrigin).toString();
const submissionOrigin = new URL(action).origin;
const html = `<!doctype html><html><body>
  <form id="token-transfer" method="POST" action="${encodeHtmlAttribute(action)}">
    <input type="hidden" name="sessionToken" value="${encodeHtmlAttribute(sessionToken)}" />
    <input type="hidden" name="renewalToken" value="${encodeHtmlAttribute(renewalToken)}" />
    <input type="hidden" name="destination"  value="${encodeHtmlAttribute(destination)}" />
  </form>
  <script>document.getElementById('token-transfer').submit();</script>
</body></html>`;
 
return new NextResponse(html, {
  headers: {
    'content-type': 'text/html; charset=utf-8',
    'cache-control': 'no-store',
    'referrer-policy': 'no-referrer',
    'x-frame-options': 'DENY',
    'content-security-policy': `default-src 'none'; script-src 'unsafe-inline'; form-action ${submissionOrigin}`,
  },
});
  • encodeHtmlAttribute, a renamed helper, reduces attribute injection through token and destination values.
  • X-Frame-Options: DENY blocks framing; CSP form-action restricts submission to settlement's Origin.
  • no-referrer removes Referer but must change alongside the earlier Origin-validation proposal.
  • script-src unsafe-inline permits all inline scripts. Input escaping does not provide a nonce's guarantees.

The remaining proposals include a per-request nonce with nonce script-src, frame-ancestors none, and base-uri none. The Referrer-Policy change and receiving Origin validation discussed earlier have not been applied either.


The handoff now follows this sequence.

text
[메인 호스트]
  정산관리 메뉴 클릭
        │  window.open('/api/finance/bridge?destination=/finance/overview')
        ▼
  GET /api/finance/bridge               ← 메인 호스트의 BFF
   - HttpOnly 쿠키에서 access/refresh 토큰 읽기
   - 토큰을 hidden input에 담은 auto-submit form HTML 반환
   - no-store / referrer=no-referrer / DENY / CSP(unsafe-inline, form-action=정산도메인)
        │  document.forms[0].submit()  (POST, 토큰은 body)
        ▼
[정산 호스트]
  POST /api/finance                     ← 정산 호스트의 BFF
   - 현재 Origin 화이트리스트 없음 (login-CSRF 보강 필요)
   - formData에서 토큰 꺼내 HttpOnly 쿠키로 set
   - resolveDestination(destination)으로 pathname만 추출 (prefix 경계 보강 필요)
        │  303 redirect → destination
        ▼
  /finance/overview  (정산 호스트에 토큰 쿠키 보유 → 로그인된 상태)

The form still uses no-referrer and unsafe-inline. The missing nonce and Origin check shown in the diagram remain unresolved.

After transfer, settlement's Axios interceptor prepares authenticated requests. It cannot read HttpOnly cookies directly, so it retrieves the token through a Server Function and adds a header. The refresh queue is separate: it waits only when renewal is underway.

The aliased readServerCookie reads a cookie on the server and returns its value to browser JavaScript. That adds a network call and makes the returned token readable by XSS. Since it accepts a cookie name, the server also needs an allowed-name list. Keeping tokens out of browser JavaScript would require the BFF to make backend API calls itself.

typescript
// 공용 Axios 인스턴스
instance.interceptors.request.use(async (config) => {
  if (requiresAuth) {
    const sessionToken = await readServerCookie(authCookieKey('sessionToken'));
    if (sessionToken) {
      config.headers['Authorization'] = `Bearer ${sessionToken}`;
    }
  }
  return config;
});

A 401 starts renewal. Requests arriving meanwhile wait in the refresh subscriber queue and retry together with the new token.

typescript
type PendingAuthRequest = {
  resumeWithToken: (token: string) => void;
  rejectWithError: (error: unknown) => void;
};
 
let refreshInProgress = false;
let pendingAuthRequests: PendingAuthRequest[] = [];
 
const notifyRefreshSuccess = (token: string) => {
  const requests = pendingAuthRequests;
  pendingAuthRequests = [];
  requests.forEach(({ resumeWithToken }) => resumeWithToken(token));
};
 
const notifyRefreshFailure = (error: unknown) => {
  const requests = pendingAuthRequests;
  pendingAuthRequests = [];
  requests.forEach(({ rejectWithError }) => rejectWithError(error));
};
 
// 401 처리: 갱신 중이면 새 토큰을 구독하며 대기
if (refreshInProgress) {
  return new Promise((resolve, reject) => {
    pendingAuthRequests.push({
      resumeWithToken: (token) => {
        failedRequest.headers = { ...failedRequest.headers, Authorization: `Bearer ${token}` };
        resolve(instance(failedRequest));
      },
      rejectWithError: reject,
    });
  });
}

The queue clears on both success and failure, and the renamed _hasRetried flag prevents a repeated 401 loop for one request. It lives only in one tab's memory, so it cannot coordinate refresh or logout between hosts and tabs.

Posting a token and storing it in an HttpOnly cookie did not keep it entirely on the server. Preparing API requests returned it to JavaScript, while both hosts retained copies.


A one-time code exchange needed shared state

An OAuth-style exchange sends only a one-time authorization code through the browser. Settlement exchanges it server to server for tokens, keeping the tokens out of the browser. That is a safer way to perform this transfer.

The constraint at the time was:

“Do not add backend resources.”

The change had to stay within frontend deployment without modifying the product backend. Next.js Route Handlers could implement an exchange API, but one-time use also needed storage shared across hosts and instances, TTL, atomic consumption, and session binding. Operating that shared state was outside the work.

I therefore used the frontend-owned /api/finance/bridge and /api/finance handlers to read and store tokens. This avoided new shared state, but did not provide the protections of an authorization-code exchange.

Transferring refresh tokens, coordinating renewal and logout, and preventing replay remain unresolved. Short access-token lifetimes and HTTPS do not stop body collection or XSS. If shared storage and an exchange API become available, /api/finance/bridge provides a starting point for replacing the transfer.


A Next.js 16.3 upgrade exposed the matcher

Three months after splitting hosts, upgrading Next.js 14.2 to 16.3.0 broke settlement. Next.js 16 defaults to Turbopack and replaces middleware.ts with Node-runtime proxy.ts. I carried the old matcher through the migration.

Conditions behind the failure

/((?!api).*) also catches /_next/**. Settlement chunks redirected 307 to the main host because they were not settlement paths.

text
GET <https://finance.example.net/_next/static/chunks/main.js>
  → 307 Location: <https://app.example.com/_next/static/chunks/main.js>

I compared old and new SSR HTML for the same screen.

tsx
<!-- 14.2.33, webpack: script 20개 중 crossorigin 0개 -->
<script src="/_next/static/chunks/main-app.js" async=""></script>
 
<!-- 16.3.0, Turbopack: script 18개 중 crossorigin="" 11개 -->
<script src="/_next/static/chunks/client.js" async="" crossorigin=""></script>

An empty crossorigin means anonymous rather than being ignored, switching the request to CORS. Three conditions combined:

  1. The matcher redirected settlement /_next/** to the main host.
  2. Version 16.3.0 added crossorigin="" to some chunks.
  3. Main-host static responses lacked ACAO for settlement's Origin.

The static-asset redirect had existed from the start. The new crossorigin attribute changed how the browser requested those assets, and CORS began blocking requests it had previously accepted.

Evidence of an upstream regression

The client reference manifest's moduleLoading.crossOrigin differed.

text
// 16.2.12
"moduleLoading": { "prefix": "", "crossOrigin": null }
// 16.3.0
"moduleLoading": { "prefix": "", "crossOrigin": "none" }

React turned the string none into an empty crossorigin attribute. Only client component chunks preinitialized through moduleLoading received it, which explains why the HTML comparison showed it on 11 of 18 chunks rather than every script.

I found matching upstream reports and a proposed fix.

The initial #96834 proposal closed without merging. #97164 instead omitted the field for CrossOrigin::None and entered canary on August 12. A later 16.3.6 portal build also lacked the field. The Next.js upgrade article follows that build-output check.

Redirecting pages rather than static assets

First, I excluded API and static paths from the matcher.

typescript
export const config = {
  matcher: ['/((?!api/|_next/|favicon\\.ico|assets/).*)'],
};

I also preserved query strings while fixing redirects. Passing a //-prefixed pathname into new URL(pathname, domain) can select a different host as a protocol-relative URL. I now create new URL(domain) first, then assign pathname and search. Next.js normalized the path earlier in deployment, so that issue did not reproduce there; the URL-building function now preserves the host itself. Both hosts were also added to experimental.serverActions.allowedOrigins.

typescript
const destinationUrl = new URL(domain);
destinationUrl.pathname = request.nextUrl.pathname;
destinationUrl.search = request.nextUrl.search;
return NextResponse.redirect(destinationUrl);

Before the upstream fix reached a stable release, I pinned ~16.2.12. ^16.2.12 would still allow 16.3.x and could reinstall the same regression.

Error-collection CORS uses an exact allowlist and Vary: Origin but permits credentials on every path. Separately from the matcher, it needs narrowing to error-collection routes.

Paths that should stay outside the matcher

The change stops /_next/** redirects. Exact /api and /_next paths, robots.txt, sitemap.xml, manifests, and public fonts also need classification against the actual assets.

unstable_doesProxyMatch can exercise page, API, and static-path combinations. Real requests also need to retain chunk responses, queries, and environment-defined hosts on both domains. Server Action Origin/Host combinations and CORS preflight and credential scope use the same host settings. Releasing the version pin calls for comparing the same screen's SSR HTML again.