You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/02 22:46:10 UTC

(grails-core) 01/01: Require authentication for Forge analytics report endpoint

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch fix/forge-analytics-security
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 2aa481cd1b234fd45ed5c3990e2e517f3047d1c7
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 2 18:45:26 2026 -0400

    Require authentication for Forge analytics report endpoint
    
    The Forge analytics `POST /analytics/report` endpoint previously accepted
    unauthenticated writes, letting anyone inject fabricated project-generation
    statistics. Secure it with a Google-issued bearer JWT that is validated by
    signature (Google JWKS), issuer, audience, and a caller-subject claim pinned
    to the Forge web service account's unique id, so only the intended caller can
    report analytics. Public read endpoints (`GET /top/*`) stay anonymous.
    
    Also add an HSTS response filter to the Forge web (Netty) app and wire the
    analytics audience and caller-subject env vars through the Cloud Run deploy
    workflows and README.
    
    Assisted-by: claude-code:claude-opus-4-8
---
 .github/workflows/forge-deploy-next.yml            |   5 +-
 .github/workflows/forge-deploy-prev-snapshot.yml   |   5 +-
 .github/workflows/forge-deploy-prev.yml            |   5 +-
 .github/workflows/forge-deploy-release.yml         |   9 +-
 .github/workflows/forge-deploy-snapshot.yml        |   5 +-
 grails-forge/README.md                             |   8 +-
 .../grails-forge-analytics-postgres/build.gradle   |  10 ++
 .../AnalyticsCallerJwtClaimsValidator.java         |  44 ++++++
 .../analytics/postgres/AnalyticsController.java    |   8 +
 .../src/main/resources/application.yml             |  18 +++
 .../AnalyticsControllerSecuritySpec.groovy         | 162 +++++++++++++++++++++
 .../postgres/StoreGeneratedProjectStatsSpec.groovy |  36 +++--
 grails-forge/grails-forge-web-netty/build.gradle   |   7 +
 .../forge/netty/security/HstsHeaderFilter.java     |  45 ++++++
 .../src/main/resources/application.yml             |   9 +-
 .../netty/security/HstsHeaderFilterSpec.groovy     |  54 +++++++
 16 files changed, 404 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/forge-deploy-next.yml b/.github/workflows/forge-deploy-next.yml
index 2774c362f3..c3b418a8f1 100644
--- a/.github/workflows/forge-deploy-next.yml
+++ b/.github/workflows/forge-deploy-next.yml
@@ -98,7 +98,7 @@ jobs:
           gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-next
           --image $IMAGE_NAME
           --region us-central1
-          --update-env-vars=HOSTNAME="next.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }}
+          --update-env-vars=HOSTNAME="next.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-next-cjm [...]
           --platform managed
           --allow-unauthenticated
           --service-account=${{ secrets.GCLOUD_EMAIL }}
@@ -155,4 +155,5 @@ jobs:
       - name: "🚀 Deploy Docker image"
         run: |
           gcloud components install beta --quiet
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-next --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
+          CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')"
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-next --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-next-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
diff --git a/.github/workflows/forge-deploy-prev-snapshot.yml b/.github/workflows/forge-deploy-prev-snapshot.yml
index 188a6cdf1f..d0a91922e7 100644
--- a/.github/workflows/forge-deploy-prev-snapshot.yml
+++ b/.github/workflows/forge-deploy-prev-snapshot.yml
@@ -98,7 +98,7 @@ jobs:
           gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-prev-snapshot
           --image $IMAGE_NAME
           --region us-central1
-          --update-env-vars=HOSTNAME="prev-snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }}
+          --update-env-vars=HOSTNAME="prev-snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics [...]
           --platform managed
           --allow-unauthenticated
           --service-account=${{ secrets.GCLOUD_EMAIL }}
@@ -155,4 +155,5 @@ jobs:
       - name: "🚀 Deploy Docker image"
         run: |
           gcloud components install beta --quiet
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
\ No newline at end of file
+          CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')"
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-snapshot-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
diff --git a/.github/workflows/forge-deploy-prev.yml b/.github/workflows/forge-deploy-prev.yml
index 5ffc0ab89b..11715a4ce4 100644
--- a/.github/workflows/forge-deploy-prev.yml
+++ b/.github/workflows/forge-deploy-prev.yml
@@ -98,7 +98,7 @@ jobs:
           gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-prev
           --image $IMAGE_NAME
           --region us-central1
-          --update-env-vars=HOSTNAME="prev.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }}
+          --update-env-vars=HOSTNAME="prev.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-cjm [...]
           --platform managed
           --allow-unauthenticated
           --service-account=${{ secrets.GCLOUD_EMAIL }}
@@ -155,4 +155,5 @@ jobs:
       - name: "🚀 Deploy Docker image"
         run: |
           gcloud components install beta --quiet
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
\ No newline at end of file
+          CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')"
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-prev --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-prev-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
diff --git a/.github/workflows/forge-deploy-release.yml b/.github/workflows/forge-deploy-release.yml
index 164b74295d..16e5fd7154 100644
--- a/.github/workflows/forge-deploy-release.yml
+++ b/.github/workflows/forge-deploy-release.yml
@@ -103,9 +103,9 @@ jobs:
           release_version: ${{ needs.build.outputs.release_version }}
           IMAGE_NAME: us-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_APP_NAME }}/${{ secrets.GCP_APP_NAME }}:${{ needs.build.outputs.release_version }}
         run: |
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="latest.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_UR [...]
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="latest.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_UR [...]
           version="$(echo "${release_version//./}" | tr '[A-Z]' '[a-z]')"
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="grailsforge-600-cjmq3uyfcq-uc.a.run.app",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${ [...]
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=HOSTNAME="grailsforge-600-cjmq3uyfcq-uc.a.run.app",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_LATEST_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_LATEST_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${ [...]
   deployAnalytics:
     name: "Deploy analytics to Google Cloud Run"
     runs-on: ubuntu-24.04
@@ -163,6 +163,7 @@ jobs:
           IMAGE_NAME: us-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_APP_NAME }}/${{ secrets.GCP_APP_NAME }}-analytics:${{ needs.build.outputs.release_version }}
         run: |
           gcloud components install beta --quiet
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-latest --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
+          CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')"
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-latest --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-latest-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
           version="$(echo "${release_version//./}" | tr '[A-Z]' '[a-z]')"
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-$version --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-$version --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-$version-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
diff --git a/.github/workflows/forge-deploy-snapshot.yml b/.github/workflows/forge-deploy-snapshot.yml
index c880e7bc80..c302ed078f 100644
--- a/.github/workflows/forge-deploy-snapshot.yml
+++ b/.github/workflows/forge-deploy-snapshot.yml
@@ -96,7 +96,7 @@ jobs:
           gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-snapshot
           --image $IMAGE_NAME
           --region us-central1
-          --update-env-vars=HOSTNAME="snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }}
+          --update-env-vars=HOSTNAME="snapshot.grails.org",CORS_ALLOWED_ORIGIN="https://start.grails.org",CORS_ALLOWED_ORIGIN_APACHE="https://grails.apache.org",GITHUB_OAUTH_APP_CLIENT_ID=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_ID }},GITHUB_OAUTH_APP_CLIENT_SECRET=${{ secrets.GH_OAUTH_SNAPSHOT_CLIENT_SECRET }},GITHUB_USER_AGENT=${{ secrets.GH_USER_AGENT }},GITHUB_REDIRECT_URL=${{ secrets.GH_REDIRECT_URL }},GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-snap [...]
           --platform managed
           --allow-unauthenticated
           --service-account=${{ secrets.GCLOUD_EMAIL }}
@@ -151,4 +151,5 @@ jobs:
       - name: "🚀 Deploy Docker image"
         run: |
           gcloud components install beta --quiet
-          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot --image $IMAGE_NAME --region us-central1 --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
+          CALLER_SUBJECT="$(gcloud iam service-accounts describe ${{ secrets.GCLOUD_EMAIL }} --format='value(uniqueId)')"
+          gcloud run deploy ${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot --image $IMAGE_NAME --region us-central1 --update-env-vars=GRAILS_FORGE_ANALYTICS_AUDIENCE="https://${{ secrets.GCP_PROJECT_ID }}-analytics-snapshot-cjmq3uyfcq-uc.a.run.app",GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT="$CALLER_SUBJECT" --platform managed --allow-unauthenticated --service-account=${{ secrets.GCLOUD_EMAIL }}
diff --git a/grails-forge/README.md b/grails-forge/README.md
index b24f32fb13..ecc8626bf7 100644
--- a/grails-forge/README.md
+++ b/grails-forge/README.md
@@ -201,6 +201,7 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot`
 | `MICRONAUT_ENVIRONMENTS`                         | `gcp`                                                                                                                                  |
 | `MICRONAUT_HTTP_SERVICES_ANALYTICS_READ_TIMEOUT` | `20s`                                                                                                                                  |
 | `MICRONAUT_HTTP_SERVICES_ANALYTICS_URL`          | URL of cloud run `grailsforge-analytics-latest`                                                                                        |
+| `GRAILS_FORGE_ANALYTICS_AUDIENCE`                | URL of cloud run `grailsforge-analytics-latest`                                                                                        |
 | `GITHUB_OAUTH_APP_CLIENT_ID`                     | The Client ID shared after creating GitHub OAuth App                                                                                   |
 | `GITHUB_OAUTH_APP_CLIENT_SECRET`                 | The Client secret shared after creating the GitHub OAuth app                                                                           |
 | `GITHUB_USER_AGENT`                              | URL of the grails-forge-ui static website. For example, `https://start.grails.org/`. The default value is `https://start.grails.org/`. |
@@ -214,6 +215,8 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot`
 | `DB_NAME`                      | `grailsforge-production`                         |
 | `MICRONAUT_ENV_DEDUCTION`      | `false`                                          |
 | `MICRONAUT_ENVIRONMENTS`       | `gcp`                                            |
+| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-latest` |
+| `GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT` | Unique ID of the service account used by `grailsforge-latest` |
 | `DATASOURCES_DEFAULT_USERNAME` | `***`                                            |
 | `DATASOURCES_DEFAULT_PASSWORD` | `***`                                            |
 | `DATASOURCES_DEFAULT_URL`      | `jdbc:postgresql:///grailsforge-production`      |
@@ -229,6 +232,7 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot`
 | `MICRONAUT_ENVIRONMENTS`                         | `gcp`                                                                                                                                                  |
 | `MICRONAUT_HTTP_SERVICES_ANALYTICS_READ_TIMEOUT` | `20s`                                                                                                                                                  |
 | `MICRONAUT_HTTP_SERVICES_ANALYTICS_URL`          | URL of cloud run `grailsforge-analytics-snapshot`                                                                                                      |
+| `GRAILS_FORGE_ANALYTICS_AUDIENCE`                | URL of cloud run `grailsforge-analytics-snapshot`                                                                                                      |
 | `GITHUB_OAUTH_APP_CLIENT_ID`                     | The Client ID shared after creating GitHub OAuth App                                                                                                   |
 | `GITHUB_OAUTH_APP_CLIENT_SECRET`                 | The Client secret shared after creating the GitHub OAuth app                                                                                           |
 | `GITHUB_USER_AGENT`                              | URL of the grails-forge-ui static website. The default value is `https://start.grails.org/`. |
@@ -243,8 +247,8 @@ Create two databases `grailsforge-production` and `grailsforge-snapshot`
 | `DB_NAME`                      | `grailsforge-snapshot`                           |
 | `MICRONAUT_ENV_DEDUCTION`      | `false`                                          |
 | `MICRONAUT_ENVIRONMENTS`       | `gcp`                                            |
+| `GRAILS_FORGE_ANALYTICS_AUDIENCE` | URL of cloud run `grailsforge-analytics-snapshot` |
+| `GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT` | Unique ID of the service account used by `grailsforge-snapshot` |
 | `DATASOURCES_DEFAULT_USERNAME` | `***`                                            |
 | `DATASOURCES_DEFAULT_PASSWORD` | `***`                                            |
 | `DATASOURCES_DEFAULT_URL`      | `jdbc:postgresql:///grailsforge-snapshot`        |
-
-
diff --git a/grails-forge/grails-forge-analytics-postgres/build.gradle b/grails-forge/grails-forge-analytics-postgres/build.gradle
index 04fe266cf8..1d05f52337 100644
--- a/grails-forge/grails-forge-analytics-postgres/build.gradle
+++ b/grails-forge/grails-forge-analytics-postgres/build.gradle
@@ -18,6 +18,7 @@
  */
 
 plugins {
+    id 'groovy'
     id 'org.apache.grails.buildsrc.properties'
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'io.micronaut.application' version "$micronautApplicationPluginVersion"
@@ -37,6 +38,7 @@ dependencies {
     implementation "com.google.cloud.sql:postgres-socket-factory:$postgresSocketFactoryVersion"
     implementation 'io.micronaut.data:micronaut-data-jdbc'
     implementation 'io.micronaut.flyway:micronaut-flyway'
+    implementation 'io.micronaut.security:micronaut-security-jwt'
     implementation 'io.micronaut.sql:micronaut-jdbc-hikari'
     implementation 'org.postgresql:postgresql'
 
@@ -54,7 +56,15 @@ dependencies {
 
     testImplementation 'ch.qos.logback:logback-classic'
     testImplementation 'io.micronaut:micronaut-http-client'
+    testImplementation 'io.micronaut.test:micronaut-test-spock'
+    testImplementation 'org.apache.groovy:groovy'
+    testImplementation "org.spockframework:spock-core", {
+        exclude group: 'org.apache.groovy', module: 'groovy-all'
+    }
+    testImplementation 'org.apache.groovy:groovy-test'
     testImplementation 'org.testcontainers:postgresql'
+    testRuntimeOnly 'net.bytebuddy:byte-buddy'
+    testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion"
 }
 application {
     mainClass = 'org.grails.forge.analytics.postgres.Main'
diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java
new file mode 100644
index 0000000000..23ca060755
--- /dev/null
+++ b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsCallerJwtClaimsValidator.java
@@ -0,0 +1,44 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.forge.analytics.postgres;
+
+import io.micronaut.context.annotation.Value;
+import io.micronaut.core.annotation.NonNull;
+import io.micronaut.security.token.Claims;
+import io.micronaut.security.token.jwt.validator.GenericJwtClaimsValidator;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+public class AnalyticsCallerJwtClaimsValidator<T> implements GenericJwtClaimsValidator<T> {
+
+    static final String CALLER_SUBJECT_PROPERTY = "grails.forge.analytics.caller-subject";
+
+    private final String callerSubject;
+
+    public AnalyticsCallerJwtClaimsValidator(@Value("${" + CALLER_SUBJECT_PROPERTY + "}") String callerSubject) {
+        this.callerSubject = callerSubject;
+    }
+
+    @Override
+    public boolean validate(@NonNull Claims claims, T request) {
+        Object subject = claims.get(Claims.SUBJECT);
+        return callerSubject.equals(subject);
+    }
+}
diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java
index 2ac0279773..8ce49d80b9 100644
--- a/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java
+++ b/grails-forge/grails-forge-analytics-postgres/src/main/java/org/grails/forge/analytics/postgres/AnalyticsController.java
@@ -26,6 +26,8 @@ import io.micronaut.http.annotation.Get;
 import io.micronaut.http.annotation.Post;
 import io.micronaut.scheduling.TaskExecutors;
 import io.micronaut.scheduling.annotation.ExecuteOn;
+import io.micronaut.security.annotation.Secured;
+import io.micronaut.security.rules.SecurityRule;
 import org.grails.forge.analytics.Generated;
 
 import jakarta.transaction.Transactional;
@@ -47,26 +49,31 @@ public class AnalyticsController {
     }
 
     @Get("/top/features")
+    @Secured(SecurityRule.IS_ANONYMOUS)
     List<TotalDTO> topFeatures() {
         return featureRepository.topFeatures();
     }
 
     @Get("/top/jdks")
+    @Secured(SecurityRule.IS_ANONYMOUS)
     List<TotalDTO> topJdks() {
         return featureRepository.topJdkVersion();
     }
 
     @Get("/top/buildTools")
+    @Secured(SecurityRule.IS_ANONYMOUS)
     List<TotalDTO> topBuilds() {
         return featureRepository.topBuildTools();
     }
 
     @Get("/top/gorm")
+    @Secured(SecurityRule.IS_ANONYMOUS)
     List<TotalDTO> topGorm() {
         return featureRepository.topGorm();
     }
 
     @Get("/top/reloading")
+    @Secured(SecurityRule.IS_ANONYMOUS)
     List<TotalDTO> topReloading() {
         return featureRepository.topReloading();
     }
@@ -77,6 +84,7 @@ public class AnalyticsController {
      * @return A future
      */
     @Post("/report")
+    @Secured(SecurityRule.IS_AUTHENTICATED)
     @Transactional
     @ExecuteOn(TaskExecutors.IO)
     HttpStatus applicationGenerated(@NonNull @Body Generated generated) {
diff --git a/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml b/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml
index 5065d27cfa..c8595b5401 100644
--- a/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml
+++ b/grails-forge/grails-forge-analytics-postgres/src/main/resources/application.yml
@@ -6,6 +6,24 @@ datasources:
     #password: 'secret'
     driver-class-name: org.postgresql.Driver
     dialect: POSTGRES
+grails:
+  forge:
+    analytics:
+      caller-subject: ${GRAILS_FORGE_ANALYTICS_CALLER_SUBJECT}
+micronaut:
+  security:
+    authentication: bearer
+    redirect:
+      enabled: false
+    token:
+      jwt:
+        claims-validators:
+          issuer: https://accounts.google.com
+          audience: ${GRAILS_FORGE_ANALYTICS_AUDIENCE}
+        signatures:
+          jwks:
+            google:
+              url: https://www.googleapis.com/oauth2/v3/certs
 ---
 flyway:
   enabled: enabled
diff --git a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy
new file mode 100644
index 0000000000..19b64ba889
--- /dev/null
+++ b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/AnalyticsControllerSecuritySpec.groovy
@@ -0,0 +1,162 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.grails.forge.analytics.postgres
+
+import io.micronaut.context.annotation.Property
+import io.micronaut.core.annotation.NonNull
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpStatus
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.annotation.Client
+import io.micronaut.http.client.exceptions.HttpClientResponseException
+import io.micronaut.data.jdbc.runtime.JdbcOperations
+import io.micronaut.security.token.generator.TokenGenerator
+import io.micronaut.test.annotation.MockBean
+import io.micronaut.test.extensions.spock.annotation.MicronautTest
+import org.grails.forge.analytics.Generated
+import org.grails.forge.analytics.SelectedFeature
+import org.grails.forge.application.ApplicationType
+import org.grails.forge.options.DevelopmentReloading
+import org.grails.forge.options.GormImpl
+import org.grails.forge.options.JdkVersion
+import org.grails.forge.options.ServletImpl
+import spock.lang.Specification
+
+import jakarta.inject.Inject
+
+@MicronautTest
+@Property(name = 'datasources.default.enabled', value = 'false')
+@Property(name = 'flyway.enabled', value = 'false')
+@Property(name = 'micronaut.security.authentication', value = 'bearer')
+@Property(name = 'micronaut.security.redirect.enabled', value = 'false')
+@Property(name = 'micronaut.security.token.jwt.claims-validators.audience', value = 'forge-analytics')
+@Property(name = 'micronaut.security.token.jwt.claims-validators.issuer', value = 'forge-test')
+@Property(name = 'micronaut.security.token.jwt.signatures.secret.generator.secret', value = 'pleaseChangeThisSecretForANewOneAndMakeItLongEnough')
+@Property(name = 'grails.forge.analytics.caller-subject', value = '1234567890')
+class AnalyticsControllerSecuritySpec extends Specification {
+
+    @Inject @Client('/analytics') HttpClient client
+    @Inject ApplicationRepository applicationRepository
+    @Inject JdbcOperations jdbcOperations
+    @Inject TokenGenerator tokenGenerator
+
+    void 'report generation data requires bearer authentication'() {
+        given:
+        Generated generated = new Generated(
+                ApplicationType.WEB,
+                GormImpl.HIBERNATE,
+                ServletImpl.TOMCAT,
+                DevelopmentReloading.DEVTOOLS,
+                JdkVersion.DEFAULT_OPTION
+        )
+        generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')])
+
+        when:
+        client.toBlocking().exchange(HttpRequest.POST('/report', generated), String)
+
+        then:
+        HttpClientResponseException e = thrown(HttpClientResponseException)
+        e.status == HttpStatus.UNAUTHORIZED
+        0 * applicationRepository._
+        0 * jdbcOperations._
+    }
+
+    void 'top analytics data is not rejected as unauthenticated'() {
+        when:
+        client.toBlocking().exchange(HttpRequest.GET('/top/features'), String)
+
+        then:
+        HttpClientResponseException e = thrown(HttpClientResponseException)
+        e.status == HttpStatus.INTERNAL_SERVER_ERROR
+        0 * applicationRepository._
+    }
+
+    void 'report generation data rejects bearer token with wrong audience'() {
+        given:
+        Generated generated = new Generated(
+                ApplicationType.WEB,
+                GormImpl.HIBERNATE,
+                ServletImpl.TOMCAT,
+                DevelopmentReloading.DEVTOOLS,
+                JdkVersion.DEFAULT_OPTION
+        )
+        generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')])
+
+        when:
+        client.toBlocking().exchange(HttpRequest.POST('/report', generated).bearerAuth(wrongAudienceToken()), String)
+
+        then:
+        HttpClientResponseException e = thrown(HttpClientResponseException)
+        e.status == HttpStatus.UNAUTHORIZED
+        0 * applicationRepository._
+        0 * jdbcOperations._
+    }
+
+    void 'report generation data rejects bearer token from wrong caller'() {
+        given:
+        Generated generated = new Generated(
+                ApplicationType.WEB,
+                GormImpl.HIBERNATE,
+                ServletImpl.TOMCAT,
+                DevelopmentReloading.DEVTOOLS,
+                JdkVersion.DEFAULT_OPTION
+        )
+        generated.setSelectedFeatures([new SelectedFeature('google-cloud-function')])
+
+        when:
+        client.toBlocking().exchange(HttpRequest.POST('/report', generated).bearerAuth(wrongCallerToken()), String)
+
+        then:
+        HttpClientResponseException e = thrown(HttpClientResponseException)
+        e.status == HttpStatus.UNAUTHORIZED
+        0 * applicationRepository._
+        0 * jdbcOperations._
+    }
+
+    @MockBean(ApplicationRepository)
+    @NonNull
+    ApplicationRepository applicationRepository() {
+        Mock(ApplicationRepository)
+    }
+
+    @MockBean(JdbcOperations)
+    @NonNull
+    JdbcOperations jdbcOperations() {
+        Mock(JdbcOperations)
+    }
+
+    @NonNull
+    private String wrongAudienceToken() {
+        tokenGenerator.generateToken([
+                sub: '1234567890',
+                iss: 'forge-test',
+                aud: 'other-service'
+        ]).orElseThrow()
+    }
+
+    @NonNull
+    private String wrongCallerToken() {
+        tokenGenerator.generateToken([
+                sub: 'other-caller',
+                iss: 'forge-test',
+                aud: 'forge-analytics'
+        ]).orElseThrow()
+    }
+}
diff --git a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy
index 681b769642..3d467fe660 100644
--- a/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy
+++ b/grails-forge/grails-forge-analytics-postgres/src/test/groovy/org/grails/forge/analytics/postgres/StoreGeneratedProjectStatsSpec.groovy
@@ -22,11 +22,13 @@ package org.grails.forge.analytics.postgres
 import io.micronaut.context.env.Environment
 import io.micronaut.core.annotation.NonNull
 import io.micronaut.data.model.Pageable
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
 import io.micronaut.data.model.query.builder.sql.Dialect
 import io.micronaut.http.HttpStatus
-import io.micronaut.http.annotation.Body
-import io.micronaut.http.annotation.Post
 import io.micronaut.http.client.annotation.Client
+import io.micronaut.http.client.HttpClient
+import io.micronaut.security.token.generator.TokenGenerator
 import org.grails.forge.analytics.Generated
 import org.grails.forge.analytics.SelectedFeature
 import org.grails.forge.application.ApplicationType
@@ -43,7 +45,6 @@ import spock.lang.Shared
 import spock.lang.Specification
 
 import jakarta.inject.Inject
-import java.util.concurrent.CompletableFuture
 
 @MicronautTest(
         transactional = false,
@@ -62,12 +63,17 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper
         ["datasources.default.url":postgres.getJdbcUrl(),
          "datasources.default.username":postgres.getUsername(),
          "datasources.default.password":postgres.getPassword(),
-         "datasources.default.dialect": Dialect.POSTGRES.name()]
+         "datasources.default.dialect": Dialect.POSTGRES.name(),
+         "micronaut.security.token.jwt.claims-validators.audience":"forge-analytics",
+         "micronaut.security.token.jwt.claims-validators.issuer":"forge-test",
+         "micronaut.security.token.jwt.signatures.secret.generator.secret":"pleaseChangeThisSecretForANewOneAndMakeItLongEnough",
+         "grails.forge.analytics.caller-subject":"1234567890"]
     }
 
-    @Inject AnalyticsClient client
+    @Inject @Client('/analytics') HttpClient client
     @Inject ApplicationRepository repository
     @Inject FeatureRepository featureRepository
+    @Inject TokenGenerator tokenGenerator
 
     void "test save generation data"() {
         given:
@@ -81,10 +87,10 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper
         generated.setSelectedFeatures([new SelectedFeature("google-cloud-function")])
 
         when:
-        HttpStatus status = client.applicationGenerated(generated).get()
+        HttpResponse<?> response = client.toBlocking().exchange(authorizedRequest(generated))
 
         then:
-        status == HttpStatus.ACCEPTED
+        response.status == HttpStatus.ACCEPTED
 
         when:
         def application = repository.list(Pageable.UNPAGED)[0]
@@ -117,9 +123,17 @@ class StoreGeneratedProjectStatsSpec extends Specification implements TestProper
         featureRepository.topReloading()
     }
 
-    @Client("/analytics")
-    static interface AnalyticsClient {
-        @Post("/report")
-        CompletableFuture<HttpStatus> applicationGenerated(@NonNull @Body Generated generated);
+    @NonNull
+    private HttpRequest<Generated> authorizedRequest(@NonNull Generated generated) {
+        HttpRequest.POST('/report', generated).bearerAuth(bearerToken())
+    }
+
+    @NonNull
+    private String bearerToken() {
+        tokenGenerator.generateToken([
+                sub: '1234567890',
+                iss: 'forge-test',
+                aud: 'forge-analytics'
+        ]).orElseThrow()
     }
 }
diff --git a/grails-forge/grails-forge-web-netty/build.gradle b/grails-forge/grails-forge-web-netty/build.gradle
index c0fa95e7d5..41a59b416a 100644
--- a/grails-forge/grails-forge-web-netty/build.gradle
+++ b/grails-forge/grails-forge-web-netty/build.gradle
@@ -19,6 +19,7 @@
 import org.gradle.api.tasks.JavaExec
 
 plugins {
+    id 'groovy'
     id 'org.apache.grails.buildsrc.properties'
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'io.micronaut.application' version "$micronautApplicationPluginVersion"
@@ -50,8 +51,14 @@ dependencies {
 
     testImplementation 'io.micronaut:micronaut-http-client'
     testImplementation 'io.micronaut.test:micronaut-test-spock'
+    testImplementation 'org.apache.groovy:groovy'
+    testImplementation "org.spockframework:spock-core", {
+        exclude group: 'org.apache.groovy', module: 'groovy-all'
+    }
+    testImplementation 'org.apache.groovy:groovy-test'
 
     testCompileOnly 'io.micronaut:micronaut-inject-groovy'
+    testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion"
 }
 tasks.named('test', Test) {
     useJUnitPlatform()
diff --git a/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java b/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java
new file mode 100644
index 0000000000..72738b0303
--- /dev/null
+++ b/grails-forge/grails-forge-web-netty/src/main/java/org/grails/forge/netty/security/HstsHeaderFilter.java
@@ -0,0 +1,45 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.forge.netty.security;
+
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.context.annotation.Value;
+import io.micronaut.core.annotation.NonNull;
+import io.micronaut.http.MutableHttpResponse;
+import io.micronaut.http.annotation.ResponseFilter;
+import io.micronaut.http.annotation.ServerFilter;
+
+@Requires(property = HstsHeaderFilter.ENABLED_PROPERTY, value = "true")
+@ServerFilter(ServerFilter.MATCH_ALL_PATTERN)
+public class HstsHeaderFilter {
+
+    static final String ENABLED_PROPERTY = "grails.forge.security.hsts.enabled";
+    static final String HEADER_NAME = "Strict-Transport-Security";
+
+    private final String headerValue;
+
+    public HstsHeaderFilter(@Value("${grails.forge.security.hsts.header-value}") @NonNull String headerValue) {
+        this.headerValue = headerValue;
+    }
+
+    @ResponseFilter
+    void filterResponse(@NonNull MutableHttpResponse<?> response) {
+        response.getHeaders().set(HEADER_NAME, headerValue);
+    }
+}
diff --git a/grails-forge/grails-forge-web-netty/src/main/resources/application.yml b/grails-forge/grails-forge-web-netty/src/main/resources/application.yml
index 5c1997791a..3d54e0cc9f 100644
--- a/grails-forge/grails-forge-web-netty/src/main/resources/application.yml
+++ b/grails-forge/grails-forge-web-netty/src/main/resources/application.yml
@@ -29,6 +29,10 @@ micronaut:
         url: https://api.github.com
 grails:
   forge:
+      security:
+        hsts:
+          enabled: ${GRAILS_FORGE_HSTS_ENABLED:true}
+          header-value: ${GRAILS_FORGE_HSTS_HEADER:max-age=31536000; includeSubDomains; preload}
       redirect-url: ${GITHUB_REDIRECT_URL:'https://start.grails.org/'}
       github:
         client-id: ${GITHUB_OAUTH_APP_CLIENT_ID}
@@ -41,8 +45,11 @@ gcp:
       auth:
         patterns:
           - /analytics/**
+        services:
+          analytics:
+            audience: ${GRAILS_FORGE_ANALYTICS_AUDIENCE}
 jackson:
   mapper:
     ACCEPT_CASE_INSENSITIVE_ENUMS: true
   deserialization:
-    READ_UNKNOWN_ENUM_VALUES_AS_NULL: true
\ No newline at end of file
+    READ_UNKNOWN_ENUM_VALUES_AS_NULL: true
diff --git a/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy b/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy
new file mode 100644
index 0000000000..4c084bc62a
--- /dev/null
+++ b/grails-forge/grails-forge-web-netty/src/test/groovy/org/grails/forge/netty/security/HstsHeaderFilterSpec.groovy
@@ -0,0 +1,54 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.grails.forge.netty.security
+
+import io.micronaut.http.HttpRequest
+import io.micronaut.http.HttpResponse
+import io.micronaut.http.annotation.Controller
+import io.micronaut.http.annotation.Get
+import io.micronaut.http.client.HttpClient
+import io.micronaut.http.client.annotation.Client
+import io.micronaut.test.extensions.spock.annotation.MicronautTest
+import spock.lang.Specification
+
+import jakarta.inject.Inject
+
+@MicronautTest
+class HstsHeaderFilterSpec extends Specification {
+
+    @Inject @Client('/') HttpClient client
+
+    void 'adds HSTS header to HTTP responses'() {
+        when:
+        HttpResponse<String> response = client.toBlocking().exchange(HttpRequest.GET('/hsts-test'), String)
+
+        then:
+        response.headers.get(HstsHeaderFilter.HEADER_NAME) == 'max-age=31536000; includeSubDomains; preload'
+    }
+
+    @Controller('/hsts-test')
+    static class HstsTestController {
+
+        @Get
+        String index() {
+            'ok'
+        }
+    }
+}