lombocska
lombocska

Reputation: 222

Cookies, csrf protection with spring boot and next js (Render.com, Vercel.com)

I have a backend in Spring boot 3.4.2 and a frontend Next JS 15.0.4. I have authentication on backend side with google oauth2 and it works. My cooki setup locally works with localhost:3000 and localhost:8080. I use csrf protection and cors config on backend side and do not want to turn off, so my question is, how to make it work on production.

My prod setup: I have a domain, lets call it: example.com. I created a subdomain like api.example.com for the backend with domain records and it works, so the namecheap doman setup in theory is good.

My Nextjs app is on vercel. My backend app is on render.com. The domain and subdomain have been configured on both platfroms.

THE PROBLEM:

when i click on frontend to the login button, that redirects to the backend. Backend manages to authenticate the user, and it's great. But after the successful auth, when the handler redirects to the next js app to a protected page, the request (next js server side request towards backend) fails for getting any protected data, so it redirects to login, and this whole flow leds to TOO_MANY_REDIRECTIONS

I am pretty sure, my cookie setup is messed somehow with the domain and subdomain on prod env.

    @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http


            .authorizeHttpRequests(authorize -> authorize
                    .anyRequest().authenticated()
            )
            .exceptionHandling(exceptionHandlingConfigurer -> {
                exceptionHandlingConfigurer.authenticationEntryPoint(
                        new Oauth2AuthenticationEntrypoint());
            })
            .oauth2Login(customizer -> {
                customizer
                        .successHandler(new Oauth2LoginSuccessHandler(userService, appConfig));
            })
            .logout(logout -> logout
                    .logoutUrl("/logout")
                    .invalidateHttpSession(true)
                    .deleteCookies("JSESSIONID", "XSRF-TOKEN")
                    .logoutSuccessHandler((request, response, authentication) -> {
                        response.setStatus(HttpServletResponse.SC_OK);
                    })
            );
    http.addFilterBefore(debuglogFilter, UsernamePasswordAuthenticationFilter.class);

    http.cors().configurationSource(corsConfigurationSource());
    http.csrf(csrf -> csrf
            .requireCsrfProtectionMatcher(new AntPathRequestMatcher("/api/v1/books/process-images"))
            .disable());
    return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(appConfig.getAllowedOrigins()); // Your frontend URL
    configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); // Allow all HTTP methods, including OPTIONS
    configuration.setAllowedHeaders(Arrays.asList("X-XSRF-TOKEN", "Content-Type", "Authorization", "Cookie"));
    configuration.setAllowCredentials(true); // Allow credentials (cookies)


    // Create and return the CorsConfigurationSource
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration); // Apply to all endpoints
    return source;
}

@Override
public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
            .allowedOrigins(appConfig.getAllowedOrigins().get(0)) // Allow your frontend URL
            .allowedMethods("GET", "POST", "PUT", "DELETE") // Allowed HTTP methods
            .allowedHeaders("X-XSRF-TOKEN", "Content-Type", "Authorization", "Cookie")
            .allowCredentials(true); // Allow credentials (cookies, etc.)
}

@Slf4j
@RequiredArgsConstructor
public class Oauth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final CsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository(); // Use session-based CSRF storage
    private final UserService userService;
    private final AppConfig appConfig;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        log.debug("Authentication: {}", authentication);

        // Convert OAuth2 user to your app user
        DefaultOAuth2User oidcUser = (DefaultOAuth2User) authentication.getPrincipal();
        AppUser appUser = AppUser.fromGoogleUser(oidcUser);

        userService.findOrCreateUser(appUser);

        AppAuthenticationToken token = new AppAuthenticationToken(appUser);
        SecurityContextHolder.getContext().setAuthentication(token);

        CsrfToken csrfToken = csrfTokenRepository.generateToken(request);
        csrfTokenRepository.saveToken(csrfToken, request, response);

//        Cookie csrfCookie = new Cookie("XSRF-TOKEN", csrfToken.getToken());
//        csrfCookie.setPath("/");
//        csrfCookie.setSecure(false); // Set to true in production with HTTPS
//        csrfCookie.setHttpOnly(false); // Allow JS to read it
        Cookie csrfCookie = new Cookie("XSRF-TOKEN", csrfToken.getToken());
        csrfCookie.setPath("/");
        csrfCookie.setSecure(true); // MUST be true in production with HTTPS
        csrfCookie.setHttpOnly(false); // JavaScript should access it
//        csrfCookie.setDomain("example.com"); // Ensure it's accessible from frontend
        csrfCookie.setAttribute("SameSite", "None"); // Enable cross-site requests

//        response.addCookie(csrfCookie);
        response.addCookie(csrfCookie);

        log.debug("Set CSRF token: {}", csrfToken.getToken());

        // 🔹 Redirect to frontend after setting CSRF token
        response.sendRedirect(appConfig.getRedirectUrlAfterSuccessfulLogin());
    }
}

For debugging purposes I made this class


@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        log.debug("JWT Filter Invoked for: {}", request.getRequestURI()); // Ensure logging works

        String csrfTokenHeader = request.getHeader("X-XSRF-TOKEN");
        String csrfTokenCookie = getCookie(request, "XSRF-TOKEN");

        log.debug("CSRF Token from Header: {}", csrfTokenHeader);
        log.debug("CSRF Token from Cookie: {}", csrfTokenCookie);

        if (csrfTokenHeader != null && csrfTokenCookie != null && csrfTokenHeader.equals(csrfTokenCookie)) {
            log.debug("CSRF Token Match!");
        } else {
            log.debug("CSRF Token Mismatch!");
        }

        chain.doFilter(request, response);
    }

    private String getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (name.equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}

logs from it:

JWT Filter Invoked for: /api/v1/books

CSRF Token from Header: b6d4a602-21a3-48e1-9fb6-1ed4cea8c3f6

CSRF Token from Cookie: null

But I tried many configs and there were cases when it was the opposit, so there was a CSRF token from Cookie and null from Header...

My frontend fetchServer.ts


export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  const cookieStore = await cookies();
  const XSRF_TOKEN = cookieStore.get("XSRF-TOKEN")?.value;

  if (!XSRF_TOKEN) {
    console.error("CSRF Token is missing from cookies!");
  }

  const headers = new Headers({
    "Content-Type": "application/json",
    ...options.headers,
  });

  if (XSRF_TOKEN) {
    headers.append("X-XSRF-TOKEN", XSRF_TOKEN);
  }

  const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL?.replace(/\/$/, "");
  const fullUrl = `${baseUrl}/api/v1${url}`;

  console.log("Requesting:", fullUrl, "with headers:", headers);

  return fetch(fullUrl, {
    ...options,
    headers,
    credentials: "include", 
  });
}

my frontend client side fetch

export async function fetchWithAuth(url: string, options: RequestInit = {}) {
    if (typeof document === "undefined") {
        throw new Error("fetchWithAuth cannot be used on the server.");
    }

    // Extract JSESSIONID from document.cookie manually
    const JSESSION = document.cookie
        .split("; ")
        .find((row) => row.startsWith("JSESSIONID="))
        ?.split("=")[1];

    const csrfToken = document.cookie
        .split("; ")
        .find((row) => row.startsWith("XSRF-TOKEN="))  // Spring Security uses XSRF-TOKEN
        ?.split("=")[1];

    const headers = {
        ...(options.headers || {}),
        ...(JSESSION ? { Cookie: `JSESSIONID=${JSESSION}` } : {}),
        ...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
    };

    const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL?.replace(/\/$/, "");
    const fullUrl = `${baseUrl}${url}`;

    return fetch(fullUrl, { ...options, headers, credentials: "include" });
}

I tried so many ways, that I feel no more options there. Please try to help me.

Another options that came to my mind to solve this situation:

Upvotes: 0

Views: 30

Answers (0)

Related Questions