angularexpresscookiesangular-ssr

How to send cookies from server to client during SSR (Angular 20)


I am using HttpOnly Cookies for JWT authentication in my Angular v20 app. I have SSR enabled.

During Login, the cookies are sent to the client. I have an HttpInterceptor that seems to work to forward these cookies for requests made from the server. I'm not sure why this works (as they are HttpOnly) so I could be mistaken. I am logging based on isPlatformBrowser in the interceptor to tell my where the request was sent from.

cookie.interceptor.ts

export const cookieInterceptor: HttpInterceptorFn = (req, next) => {
  const ssrRequest: Request | null = inject(REQUEST);
  const shouldForwardCookies: boolean = true; // in case I have external calls later
  const platformId = inject(PLATFORM_ID);
  const isServer = isPlatformServer(platformId);
  const platform = isServer ? 'Server' : 'Client';
  console.log(`[${platform}] HTTP Request: ${req.method} ${req.urlWithParams}`);

  if (shouldForwardCookies) {
    const clonedRequest = req.clone({
      withCredentials: true,
      ...(ssrRequest
        ? {
            headers: req.headers.append(
              'Cookie',
              ssrRequest.headers.get('cookie') ?? ''
            )
          }
        : {})
    });
    return next(clonedRequest);
  }
  return next(req);
};

When the server calls the refresh API, the API responds with the new tokens as expected. During the refresh, a new accessToken is created as well as a new refreshToken but this never gets set on the client. So when subsequent request are made they are all unauthorized.

I thought what I wanted would not be possible at all. But since I can seemingly send the cookies from the client to the server, my hope is rekindled.


Solution

  • I was able to use HttpInterceptors to forward cookies from both sides by making use of the REQUEST and RESPONSE_INIT tokens.

    I got rid of the cookie.interceptor and added the cookie forwarding logic to my jwt.interceptor. Previously, this was used only to retry requests in case of a 401 after a token refresh. The cookie forwarding was added here as it was needed to ensure the refreshed cookies were forwarded to the retried request. I thought it would be better to have all that happen in the same place.

    I created a cookie-helper util like below:

    export function forwardResponseCookies(
      response: HttpResponse<any>,
      responseInit: ResponseInit
    ): ResponseInit {
      const cookies =
        typeof response.headers.getAll === 'function'
          ? (response.headers.getAll('Set-Cookie') ?? [])
          : (() => {
              const single = response.headers.get('Set-Cookie');
              return single ? [single] : [];
            })();
      if (!responseInit.headers || !(responseInit.headers instanceof Headers)) {
        responseInit.headers = new Headers(responseInit.headers ?? undefined);
      }
      for (const cookie of cookies) {
        (responseInit.headers as Headers).append('Set-Cookie', cookie);
      }
      return responseInit;
    }
    

    My jwt.interceptor looks like this:

    export const jwtInterceptor: HttpInterceptorFn = (req, next) => {
      // declarations and injections
    
      const authReq = req.clone({
        withCredentials: true,
        setHeaders: {
          Cookie: (isServer && ssrRequest?.headers.get('cookie')) || ''
        }
      });
    
      return next(authReq).pipe(
        map((response: HttpEvent<any>) => {
          if (response instanceof HttpResponse && responseInit) {
            forwardResponseCookies(response, responseInit);
          }
          return response;
        }),
        catchError((error: HttpErrorResponse) => {
          return tokenService.refreshToken().pipe(
            map((response) => {
              if (response instanceof HttpResponse && responseInit) {
                forwardResponseCookies(response, responseInit);
              }
            }),
            switchMap(() => {
              const retriedRequest = authReq.clone({
                withCredentials: true,
                setHeaders: {
                  [RETRY_HEADER]: 'true',
                  Cookie:
                    (isServer &&
                      (responseInit?.headers as Headers)?.get('Set-Cookie')) ||
                    ''
                }
              });
              return next(retriedRequest);
            }),
            catchError((refreshError) => {
              // handle refresh token failure (e.g., redirect to login)
                })
              );
            })
          );
        })
      );
    };
    

    I have ommitted inject statements and some declaration as well as some orchestration logic for brevity.

    This is how I think it works:

    We forward the cookies that the browser sends to the Express.js server (from ssrRequest) when the request is cloned (to authReq).

    When the API server responds we set the cookies from response in the Express.js response to the browser via ResponseInit. Your Express.js server will render the HTML response for your browser, but as the cookies were set in ResponseInit they get added to the browser's cookie store.