DEV Community

Sudhir
Sudhir

Posted on

Bridging Cookie-Based SSR Authentication in TanStack Start with JWT-Protected NestJS APIs

The Problem

You have an SSR application using HTTP-only cookies for authentication, but your NestJS backend expects JWT tokens in Authorization headers.

The mismatch:

  • Cookies → SSR server ✅
  • Cookies → API server ❌
  • Headers → API server ✅

The solution: SSR server extracts JWT from cookies and forwards it in HTTP headers.

The Flow

Implementation

NestJS API (Standard JWT Guard)

@Controller("application")
export class ApplicationController {
  @Post(":jobId/upload-resume/:userId")
  @UseGuards(JwtAuthGuard) // Check if auth headers are present and valid
  async uploadResume(
    @Param("jobId") jobId: string,
    @Param("userId") userId: string,
    @Body() body: ResumeSubmitBody
  ) {
    // Implementation here
  }
}
Enter fullscreen mode Exit fullscreen mode

JwtAuthGuard

// auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor() {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      throw new UnauthorizedException("No authorization token provided.");
      return false;
    }

    const token = authHeader.split(" ")[1];

    const { data: validationResult, error } = await validateJWT(token);
    if (error) {
      throw new UnauthorizedException("Failed to validate JWT");
      return false;
    }
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

SSR Server - Cookie Extraction

TanStack ServerFn:

// resume.serverFn.ts
export const submitResumeServerFn = createServerFn({ method: 'POST' })
  .middleware([
        authMiddleware, // protects the function and provides user context
        withApi // wrapper for fetch call to include authorization headers
    ])
  .inputValidator(SubmitResumeRequestSchema)
  .handler(async ({ data, context }) => {
    const { user, apiClient } = context

    return apiClient.fetch({
      jobId: data.jobId,
      userId: user.id,
      // ...
    })
  })
};
Enter fullscreen mode Exit fullscreen mode

authMiddleware validates the session and provides access_token to context.

// auth.middleware.ts
export const authMiddleware = createMiddleware().server(async ({ next }) => {
  const user = await getUser();

  if (!user) {
    throw redirect({ to: "/signin" });
  }

  const supabaseClient = getSupabaseServerClient();
  const { data: sessionResult, error: sessionError } =
    await supabaseClient.auth.getSession();

  if (sessionError) return Promise.reject(sessionError);

  const accessToken = sessionResult.session?.access_token;

  return await next({
    context: {
      user,
      auth: { accessToken },
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

withApi Provides a wrapper for the fetch call to include authorization headers

export const withApi = createMiddleware().server(async ({ next, context }) => {
  const contextWithAuth = context as any;
  if (contextWithAuth?.auth?.accessToken) {
    // Include Authorization headers in outgoing request
    return await next({
      context: {
        apiClient: {
          fetch: (...args) =>
            fetch(...args, {
              headers: {
                Authorization: `Bearer ${contextWithAuth.auth.accessToken}`,
              },
            }),
        },
      },
    });
  }

  return await next({
    context: {
      apiClient: {
        fetch: fetch, // default fetch api
      },
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Note: For this implementation, the order of middlewares in the controller matters. withApi middleware can

Key Takeaways

  • ✅ HTTP-only cookies prevent XSS attacks
  • ✅ SSR server safely bridges cookie auth → JWT headers
  • ✅ Client code never touches JWT tokens
  • ❌ Don't access HTTP-only cookies from client JavaScript
  • ❌ Don't pass JWT tokens as props to client components

Cookie Configuration

// Set cookie with proper security attributes
setCookie("auth_token", jwt, {
  httpOnly: true, // Prevent JavaScript access
  secure: true, // HTTPS only
  sameSite: "strict", // CSRF protection
  maxAge: 60 * 60 * 24 * 7, // 7 days
});
Enter fullscreen mode Exit fullscreen mode

Done!

The SSR server is your authentication bridge: it extracts JWTs from HTTP-only cookies and forwards them as headers to your API, keeping tokens away from client-side JavaScript.

JwtAuthGuard

// auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor() {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      throw new UnauthorizedException("No authorization token provided.");
      return false;
    }

    const token = authHeader.split(" ")[1];

    const { data: validationResult, error } = await validateJWT(token);
    if (error) {
      throw new UnauthorizedException("Failed to validate JWT");
      return false;
    }
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

SSR Server - Cookie Extraction

TanStack ServerFn:

// resume.serverFn.ts
export const submitResumeServerFn = createServerFn({ method: 'POST' })
  .middleware([
        authMiddleware, // protects the function and provides user context
        withApi // wrapper for fetch call to include authorization headers
    ])
  .inputValidator(SubmitResumeRequestSchema)
  .handler(async ({ data: formData, context }) => {
    const { user, apiClient } = context
    const file = formData.get('file') as File
    if (!(file instanceof File)) throw new Error('[file] not found')

    const conferenceFormFields = formDataToJson(formData, {})

    return apiClient.fetch({
      jobId: formData.get('jobId') as string,
      userId: user.id,
      file: file,
      // ...
    })
  })
};
Enter fullscreen mode Exit fullscreen mode

authMiddleware validates the session and provides access_token to context.

// auth.middleware.ts
export const authMiddleware = createMiddleware().server(async ({ next }) => {
  const user = await getUser();

  if (!user) {
    throw redirect({ to: "/signin" });
  }

  const supabaseClient = getSupabaseServerClient();
  const { data: sessionResult, error: sessionError } =
    await supabaseClient.auth.getSession();

  if (sessionError) return Promise.reject(sessionError);

  const accessToken = sessionResult.session?.access_token;

  return await next({
    context: {
      user,
      auth: { accessToken },
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

withApi Provides a wrapper for the fetch call to include authorization headers

export const withApi = createMiddleware().server(async ({ next, context }) => {
  const contextWithAuth = context as any;
  if (contextWithAuth?.auth?.accessToken) {
    // Include Authorization headers in outgoing request
    return await next({
      context: {
        apiClient: {
          fetch: (...args) =>
            fetch(...args, {
              headers: {
                Authorization: `Bearer ${contextWithAuth.auth.accessToken}`,
              },
            }),
        },
      },
    });
  }

  return await next({
    context: {
      apiClient: {
        fetch: fetch, // default fetch api
      },
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

Note: For this implementation, the order of middlewares in the controller matters. withApi middleware can

Key Takeaways

  • ✅ HTTP-only cookies prevent XSS attacks
  • ✅ SSR server safely bridges cookie auth → JWT headers
  • ✅ Client code never touches JWT tokens
  • ❌ Don't access HTTP-only cookies from client JavaScript
  • ❌ Don't pass JWT tokens as props to client components

Cookie Configuration

// Set cookie with proper security attributes
setCookie("auth_token", jwt, {
  httpOnly: true, // Prevent JavaScript access
  secure: true, // HTTPS only
  sameSite: "strict", // CSRF protection
  maxAge: 60 * 60 * 24 * 7, // 7 days
});
Enter fullscreen mode Exit fullscreen mode

Done!

The SSR server is your authentication bridge: it extracts JWTs from HTTP-only cookies and forwards them as headers to your API, keeping tokens away from client-side JavaScript.

Top comments (0)