Fly Proxy WAF is blocking POST requests to /api/authenticate with 403 Forbidden

  • “My Spring Boot application’s login endpoint (POST /api/authenticate) is being blocked with a 403 Forbidden.”

  • “My application logs show the request never reaches my server.”

  • “The exact same code works perfectly when deployed on Render, so the issue must be with the Fly.io proxy.”

  • “Please investigate why your WAF is blocking this path. Here is a request ID from a failed attempt: 01K50DXCEYGHFP8M0EPEEDFB0S.”

fly-proxy does not have a WAF, and that request ID does not seem valid. can you run a request to your app’s endpoint adding a flyio-debug: doit request header, and then provide the full request ID including the region code? this will allow us to see what’s going on.

im not sure wether its fly issue, because i have spring boot app deployed here and its working fine

maybe check the SecurityConfiguration ?

Hi lillian,

Thank you for your response! I hope this info will help:

Name Value


origin https://zoranstepanoski-prof-website.fly.dev
path /api/authenticate
sec-fetch-site same-origin
accept-encoding gzip, deflate, br, zstd
sec-ch-ua-mobile ?0
dnt 1
authority zoranstepanoski-prof-website.fly.dev
sec-fetch-dest empty
flyio-debug doit
referer https://zoranstepanoski-prof-website.fly.dev/login
sec-ch-ua “Not;A=Brand”;v=“99”, “Google Chrome”;v=“139”, “Chromium”;v=“139”
method POST
sec-ch-ua-platform “Windows”
cache-control no-cache
sec-fetch-mode cors
priority u=1, i
scheme https
pragma no-cache
accept-language en-US,en;q=0.9,sr;q=0.8,hr;q=0.7
accept application/json, text/plain, /

vary : Origin,Access-Control-Request-Method,Access-Control-Request-Headers
x-content-type-options : nosniff
x-xss-protection : 0
pragma : no-cache
x-frame-options : DENY
transfer-encoding : chunked
fly-request-id : 01K518V8MZC5ADRE3GKNQBXJC7-cdg
flyio-debug : {“n”:“edge-cf-cdg1-b197”,“nr”:“cdg”,“ra”:“178.220.165.110”,“rf”:“Verbatim”,“sr”:“cdg”,“sdc”:“cdg1”,“sid”:“7814292b9727e8”,“st”:0,“nrtt”:0,“bn”:“worker-cf-cdg1-1590”,“mhn”:null,“mrtt”:null}
Cache-Control : no-cache, no-store, max-age=0, must-revalidate
Date : Sat, 13 Sep 2025 10:10:02 GMT
Expires : 0
Server : Fly/9fea047a (2025-09-12)
Via : 1.1 fly.io

-Body “{"username”:"admin","password":"admin","rememberMe":false}"
Invoke-WebRequest : The remote server returned an error: (403) Forbidden.
At line:1 char:1

  • Invoke-WebRequest -UseBasicParsing -Uri "https://zoranstepanoski-prof
  •   + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
      + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
    

PS. I am new to this site so I still don’t know the rules. Sorry in advance if I make some mistakes in my reply.

I did and here is the changed SecurityConfiguration.java:

@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfiguration {

    private static final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class);
    private final JHipsterProperties jHipsterProperties;

    public SecurityConfiguration(JHipsterProperties jHipsterProperties) {
        this.jHipsterProperties = jHipsterProperties;
        log.info("SECURITY CONFIG LOADED! Using robust MvcRequestMatcher for multi-chain routing. Version 13.");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
        return new MvcRequestMatcher.Builder(introspector);
    }

    // =================================================================================
    // CHAIN 1: STATELESS PUBLIC AUTH API - HIGHEST PRIORITY
    // =================================================================================
    @Bean
    @Order(1)
    public SecurityFilterChain statelessPublicApiFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
        http
            .securityMatcher(
                new OrRequestMatcher(
                    mvc.pattern(HttpMethod.POST, "/api/authenticate"),
                    mvc.pattern(HttpMethod.POST, "/api/register"),
                    mvc.pattern(HttpMethod.GET, "/api/activate"), // This is likely a GET
                    mvc.pattern(HttpMethod.POST, "/api/account/reset-password/init"),
                    mvc.pattern(HttpMethod.POST, "/api/account/reset-password/finish")
                )
            )
            .cors(withDefaults())
            .csrf(AbstractHttpConfigurer::disable) // CSRF is disabled for this chain
            .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) // All matched requests are permitted
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(exceptions ->
                exceptions
                    .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                    .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
            );
        return http.build();
    }

    // =================================================================================
    // CHAIN 2: DEFAULT APPLICATION SECURITY - LOWER PRIORITY
    // =================================================================================
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
        http
            .cors(withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .headers(headers ->
                headers
                    .contentSecurityPolicy(csp -> csp.policyDirectives(jHipsterProperties.getSecurity().getContentSecurityPolicy()))
                    .frameOptions(FrameOptionsConfig::sameOrigin)
                    .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
                    .permissionsPolicyHeader(permissions ->
                        permissions.policy(
                            "camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()"
                        )
                    )
            )
            .authorizeHttpRequests(authz ->
                authz
                    .requestMatchers(mvc.pattern("/"))
                    .permitAll()
                    .requestMatchers(
                        mvc.pattern("/index.html"),
                        mvc.pattern("/*.js"),
                        mvc.pattern("/*.txt"),
                        mvc.pattern("/*.json"),
                        mvc.pattern("/*.map"),
                        mvc.pattern("/*.css")
                    )
                    .permitAll()
                    .requestMatchers(mvc.pattern("/*.ico"), mvc.pattern("/*.png"), mvc.pattern("/*.svg"), mvc.pattern("/*.webapp"))
                    .permitAll()
                    .requestMatchers(
                        mvc.pattern("/app/**"),
                        mvc.pattern("/i18n/**"),
                        mvc.pattern("/content/**"),
                        mvc.pattern("/webfonts/**"),
                        mvc.pattern("/assets/**")
                    )
                    .permitAll()
                    .requestMatchers(mvc.pattern("/*.woff2"), mvc.pattern("/*.woff"), mvc.pattern("/*.ttf"), mvc.pattern("/*.eot"))
                    .permitAll()
                    .requestMatchers(mvc.pattern("/swagger-ui/**"), mvc.pattern("/v3/api-docs/**"))
                    .permitAll()
                    .requestMatchers(mvc.pattern(HttpMethod.POST, "/api/contact-messages"))
                    .permitAll()
                    .requestMatchers(mvc.pattern(HttpMethod.GET, "/api/blog-posts"), mvc.pattern(HttpMethod.GET, "/api/blog-posts/**"))
                    .permitAll()
                    .requestMatchers(
                        mvc.pattern(HttpMethod.GET, "/api/skills"),
                        mvc.pattern(HttpMethod.GET, "/api/projects"),
                        mvc.pattern(HttpMethod.GET, "/api/project-images"),
                        mvc.pattern(HttpMethod.GET, "/api/services")
                    )
                    .permitAll()
                    .requestMatchers(
                        mvc.pattern("/management/health"),
                        mvc.pattern("/management/health/**"),
                        mvc.pattern("/management/info"),
                        mvc.pattern("/management/prometheus")
                    )
                    .permitAll()
                    .requestMatchers(mvc.pattern("/api/admin/**"))
                    .hasAuthority(AuthoritiesConstants.ADMIN)
                    .requestMatchers(mvc.pattern("/management/**"))
                    .hasAuthority(AuthoritiesConstants.ADMIN)
                    .requestMatchers(mvc.pattern("/api/**"))
                    .authenticated()
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(exceptions ->
                exceptions
                    .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                    .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
        return http.build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(
            Arrays.asList("http://localhost:9000", "http://localhost:8080", "https://zoranstepanoski-prof-website.fly.dev")
        );
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

The point is that "/api/authenticate" never passes the Order(1) block?!?!

I tried with MvcRequestMatcher.Builder too but the same result! I'm completely confused and without a clue!

im not sure when i try to curl that it just fine
curl -X POST “https://zoranstepanoski-prof-website.fly.dev/api/authenticate”
-H “Content-Type: application/json”
-H “Accept: application/json”
–data ‘{“username”:“admin”,“password”:“admin”,“rememberMe”:false}’
{“id_token”:“eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTc1Nzg0NjAyNywiYXV0aCI6IlJPTEVfQURNSU4gUk9MRV9VU0VSIiwiaWF0IjoxNzU3NzU5NjI3LCJ1c2VySWQiOjF9.OTlDNHGjnt4eS_frwd9ok87jUuzM_H8OM8fpB4wpJs91zwdV6LXUftCUQYQFrZn4ugCl01GUzzpzDZzdY4xCNQ”}

and btw now you need to change the password i think ..

1 Like

my guess there is something misconfig in CORS and allowed host

Hi erlangga,

Thank your for your quick response.

Well, accordingly to the chaptGPT this problem is not related to the CORS:

  • A CORS Error is a security feature of the browser. The browser sees that your frontend code (from https://…fly.dev) is trying to make a request to a server. If the server doesn’t explicitly permit this with the correct Access-Control-Allow-Origin headers, the browser itself blocks the request and shows an error in the developer console. The server never even processes the blocked request.

  • A Error is a response from the server. The server (or a proxy in front of it) received the request, understood it, but is explicitly refusing to fulfill it. It’s like a bouncer at a club saying, “I see you, I know what you want, but you are not allowed in.”

A Simple Table to Compare

Feature CORS Error 403 Forbidden Error (Your Situation)
Who generates the error? The Browser (Chrome, Firefox, etc.) The Server or Proxy (Fly.io)
undefined ---- ----
What is the symptom? An error message in the browser’s developer console. The network request often shows as “(failed)”. A 403 status code in the browser’s Network tab.
undefined ---- ----
Is the main request sent? Often, no. The browser sends a preflight OPTIONS request first, and if that fails, it never sends the actual . Yes. The POST request is successfully sent from the browser and received by the server/proxy.
undefined ---- ----
What is the cause? The server is missing the correct Access-Control-* response headers. The server/proxy has a security rule (like a WAF or a permission check) that is actively rejecting this specific request.
undefined ---- ----

Applying This to Your Problem

  1. We know the request is being sent: You can see the authenticate request in your browser’s Network tab. This means the browser’s CORS check passed (or wasn’t needed because it’s a same-origin request).

  2. We know the server/proxy is responding: The request doesn’t just “fail” in the browser; it gets a clear 403 Forbidden status code back from the server side.

  3. We know your application isn’t responding: Your fly logs show that your Spring Boot application never sees the request.

Conclusion: The browser successfully sends the request. The Fly.io proxy receives it. The proxy’s security rules don’t like something about the request and reject it with a 403 before it can ever reach your application.

So, while your Spring Boot application has a perfectly good CORS configuration (as shown by the vary: Origin… header in the response), that configuration never gets a chance to run because the proxy blocks the request first.’

At first I was using public class SecurityConfiguration originaly created by JHipster and it was working fine on Render cloud but because of some other issues I decided to move to fly.io and now I have an issue! Everything works fine in dev env but on fly.io it doesn’t work.

BTW, I also use Dockerfile.

Just to put more light on this issue I am adding fly.toml and Dockerfile (since I use the Docker):

fly.toml:

app = 'zoranstepanoski-prof-website'
primary_region = 'cdg'

[build]
dockerfile = "Dockerfile"

[[services]]
protocol = "tcp"
internal_port = 8080
processes = ["app"]

auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1

[services.concurrency]
type = "connections"
hard_limit = 25
soft_limit = 20

[[services.ports]]
port = 80
handlers = ["http"]
force_https = true

[[services.ports]]
port = 443
handlers = ["tls", "http"]

[[services.http_checks]]
interval = "15s"
timeout = "5s"
grace_period = "90s"
method = "get"
path = "/management/health"
protocol = "http"

[[vm]]
memory = "2gb"
cpu_kind = "performance"
cpus = 1

Dockerfile:
# ------------------------
# Stage 1: Build with Maven
# ------------------------
FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /app

# Copy everything (backend + frontend)
COPY . .

# Build backend + frontend (prod profile)
RUN ./mvnw -Pprod -DskipTests package

# ------------------------
# Stage 2: Run with JRE
# ------------------------
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Copy the built JAR from the build stage
COPY --from=build /app/target/*.jar app.jar

# Expose app port
EXPOSE 8080

# Run the app
ENTRYPOINT ["java", "-jar", "app.jar"]


I see , but i cant reproduce it on my end because it give me 200 response as you can see above

Hmmmm, strange! Maybe I am doing something wrong with fly.io proxy settings or fly.io proxy has to be set so the frontend can communicate with my Maven backend services? Client is just not able to reach the backend services. Or maybe something is wrong with my

@Configuration
public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer<WebServerFactory> {

    private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);

    private final Environment env;

    private final JHipsterProperties jHipsterProperties;

    public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) {
        this.env = env;
        this.jHipsterProperties = jHipsterProperties;
    }

    @Override
    public void onStartup(ServletContext servletContext) {
        if (env.getActiveProfiles().length != 0) {
            LOG.info("Web application configuration, using profiles: {}", (Object[]) env.getActiveProfiles());
        }
        LOG.info("Web application fully configured");
    }

    /**
     * Customize the Servlet engine: Mime types, the document root, the cache.
     */
    @Override
    public void customize(WebServerFactory server) {
        // When running in an IDE or with ./mvnw spring-boot:run, set location of the static web assets.
        setLocationForStaticAssets(server);
    }

    /**
     * This logic is only necessary when running from an IDE.
     * When running from a JAR, Spring Boot automatically finds the static assets.
     */
    private void setLocationForStaticAssets(WebServerFactory server) {
        if (server instanceof ConfigurableServletWebServerFactory servletWebServer) {
            URL resource = getClass().getResource("/static/index.html");

            // Only set the document root if we are running from the file system (protocol is "file")
            if (resource != null && "file".equals(resource.getProtocol())) {
                try {
                    File root = new File(resource.toURI());
                    // The resource is index.html, so we need its parent directory 'static'
                    File staticFolder = new File(root.getParent());
                    servletWebServer.setDocumentRoot(staticFolder);
                } catch (URISyntaxException e) {
                    throw new RuntimeException("Unable to resolve static assets location", e);
                }
            }
            // If the protocol is "jar", we do nothing. Spring Boot will handle it correctly.
        }
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = jHipsterProperties.getCors();
        if (!CollectionUtils.isEmpty(config.getAllowedOrigins()) || !CollectionUtils.isEmpty(config.getAllowedOriginPatterns())) {
            LOG.debug("Registering CORS filter");
            source.registerCorsConfiguration("/api/**", config);
            source.registerCorsConfiguration("/management/**", config);
            source.registerCorsConfiguration("/v3/api-docs", config);
            source.registerCorsConfiguration("/swagger-ui/**", config);
        }
        return new CorsFilter(source);
    }
}

Can you please show me how your public class SecurityConfiguration looks like (if it is not a secret of course)?

Ok, after some research on fly.io conclusion is that the code is OK but something is wrong with my fly-request-id.

They have to look at the log and tell me what is wrong with my fly-request-id 01K518V8MZC5ADRE3GKNQBXJC7-cdg.

I think that is the reason why the code works on your side, you have valid fly-request-id.

It is still in ‘Work in progress" state’ so the password is my least worry now. Once when I solve this problem I will be able to change the password using my ‘admin’ page on my website.

Thank you a lot for your help! Maybe I can create a ticket to someone from fly.io support team about my fly-request-id?

from the request ID I can tell that the request successfully made it to your app, and the app replied with the 403 error.
I’m not familiar with spring boot so I’m not sure what to suggest other than continue debugging your app; but it doesn’t seem like an issue with Fly at least.

ah, I managed to get it to 403!

$ curl -v -X POST https://zoranstepanoski-prof-website.fly.dev/api/authenticate \
  --json '{"username":"admin","password":"admin","rememberMe":false}' \
  -H "Origin: https://example.com"
[...]
< HTTP/2 403

Invalid CORS request

not sure if that’s what happening in your case, but might be related.

That is the point, I can open my website but if try to make any call to backend Mavan API is get 403! I ma clueles. The problem is that I don’t have paid account so I can’t ask the fly.io support team for help :frowning:

1 Like
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
    val configuration = CorsConfiguration()
    configuration.allowedOrigins = listOf("*") // Or be more specific with your customer domains
    configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
    configuration.allowedHeaders = listOf("*")
    configuration.allowCredentials = true
    val source = UrlBasedCorsConfigurationSource()
    source.registerCorsConfiguration("/**", configuration)
    return source
}

this is my cors config although its less secure tbh

Aha! There you are!
Since I don’t care about admin page right now, please try this
Go to https://zoranstepanoski-prof-website.fly.dev/
Then in exact order pres Shift+Ctrl+Alt+Z and then S (all keys have to be pressed but in exact order) then you’ll see the login at account menu item. Try to login with ‘admin’ ‘admin’ and watch the Chrome (in my case) debug window.

And neither should you. This is almost certainly an application level error, and cloud providers would be making a rod for their own back if they were to start offering software engineering services at no additional cost.

This is a debugging problem, and the best outcome is for the team who wrote the code to now dig into why an error is occurring. If you can get a 403, check your logs. SSH into your machines and/or make test network calls from inside your machine.

(Occasionally Fly employees do weigh in here, as you’ve seen, and I think it is very kind of them to do so, but I fear that it creates a dependency culture that isn’t healthy for engineers in the medium or long term.)