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

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 652cd31bf7 [#12728] fix(server): make HTTP error stack traces 
configurable (#13057)
652cd31bf7 is described below

commit 652cd31bf7f12238ab812c3f59acf84debfc8c46
Author: Nevin Zheng <[email protected]>
AuthorDate: Mon Sep 14 20:56:30 2026 -0700

    [#12728] fix(server): make HTTP error stack traces configurable (#13057)
    
    ### What changes were proposed in this pull request?
    
    HTTP error responses can now omit server-side stack traces. The main
    server, Iceberg REST and Lance REST each read `includeErrorStackTrace`
    from their own configuration, and it defaults to `true`, so existing
    clients keep today's responses.
    
    - **Main server:** `gravitino.server.webserver.includeErrorStackTrace`
    controls the `stack` field in Jersey JSON errors, the authentication and
    versioning filters, and Jetty's own error page.
    - **Iceberg REST and Lance REST:**
    `gravitino.iceberg-rest.includeErrorStackTrace` and
    `gravitino.lance-rest.includeErrorStackTrace`. Iceberg adds `stack` only
    when enabled; Lance leaves generated `detail` empty when disabled, while
    Lance exceptions that carry their own `detail` keep it.
    - **Diagnostics stay server-side:** `AuthenticationFilter` logs
    unexpected failures once at ERROR (401/403/400 client errors are not
    logged), and the authorization path logs the original throwable.
    - **Hook contract:** `JettyServer.createAuthenticationFilter` now takes
    `includeErrorStackTrace`, and implementations must not include stack
    traces when it is `false`.
    - **OpenAPI:** the `stack` field is marked deprecated.
    
    Unchanged: the `message` field, and connector and Python client
    behavior.
    
    Reviewer notes:
    
    - The Iceberg and Lance flags live in each service module, not in
    `server-common`. `IsolatedClassLoader` shares non-catalog classes with
    the main server, so a shared static would hold one value for all three
    servers.
    - `AuthenticationFilter.doFilter` runs authentication and the downstream
    chain in one `try` with typed catches, so each failure is handled and
    logged once. It keeps `ServerHealth.recordFailure` from #13067 in every
    branch.
    
    ### Why are the changes needed?
    
    Stack traces in error responses expose internal class names, line
    numbers and proxy chains to any caller, including unauthenticated ones
    (#12728). Omitting them follows [OWASP REST error-handling
    
guidance](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html#error-handling)
    and mitigates
    [CWE-209](https://cwe.mitre.org/data/definitions/209.html). The default
    stays `true` for compatibility, as agreed in review, and the docs
    recommend `false` for new deployments.
    
    Related to: #12728
    
    Follow-ups: #13107 (raw exception text in `message`), #13108 (skip
    building stack text when disabled)
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    - **New configuration keys**, all defaulting to `true`:
    `gravitino.server.webserver.includeErrorStackTrace` (Docker:
    `GRAVITINO_SERVER_WEBSERVER_INCLUDE_ERROR_STACK_TRACE`),
    `gravitino.iceberg-rest.includeErrorStackTrace`, and
    `gravitino.lance-rest.includeErrorStackTrace`. With the defaults,
    responses are unchanged.
    - **Extension API:** `JettyServer.createAuthenticationFilter()` is
    replaced by `createAuthenticationFilter(boolean
    includeErrorStackTrace)`. Subclasses that override the old method must
    update.
    - **Logging:** unexpected authentication failures are now logged at
    ERROR.
    
    ### How was this patch tested?
    
    - Unit tests cover both settings for Jersey serialization and legacy
    deserialization (`TestObjectMapperProvider`, `TestGravitinoServer`),
    Jetty's error page (`TestJettyServer`), authentication errors and their
    logging (`TestAuthenticationFilter`), Iceberg REST
    (`TestIcebergRESTUtils`, `TestIcebergAuthenticationFilter`,
    `TestRESTService`) and Lance REST (`TestLanceExceptionMapper`,
    `TestLanceMetadataAuthorizationMethodInterceptor`,
    `TestLanceAuthenticationFilter`, `TestLanceRESTService`).
    - `TestResponses` checks that error DTOs still carry the stack for
    server-side use, and `TestGravitinoInterceptionService` covers logging
    of the original throwable on the authorization path.
    - Those suites pass locally with `-PskipITs`, together with the related
    `TestAuthenticationOutOfMemoryHttp`, `TestOutOfMemoryHealthHttp`,
    `TestHttpsServerAuthentication` and `TestJettyServerConfig`.
    `spotlessCheck` passes for `common`, `server-common`, `server`,
    `iceberg-rest-server` and `lance-rest-server` on the current head.
    - The main behavioral tests were checked by inverting or removing the
    code under test, which makes them fail.
    
    ---------
    
    Co-authored-by: roryqi <[email protected]>
    Co-authored-by: roryqi <[email protected]>
---
 .../gravitino/dto/responses/TestResponses.java     |  28 ++
 conf/gravitino-iceberg-rest-server.conf.template   |   3 +
 conf/gravitino-lance-rest-server.conf.template     |   3 +
 conf/gravitino.conf.template                       |   3 +
 .../gravitino/rewrite_gravitino_server_config.py   |   2 +
 docs/gravitino-server-config.md                    |   2 +
 docs/iceberg-rest-service.md                       |   1 +
 docs/lance-rest-service.md                         |   2 +
 docs/open-api/catalogs.yaml                        |   9 +-
 docs/open-api/openapi.yaml                         |   8 +
 .../org/apache/gravitino/iceberg/RESTService.java  |   7 +-
 .../iceberg/service/IcebergRESTUtils.java          |  26 +-
 .../apache/gravitino/iceberg/TestRESTService.java  |  11 +
 .../service/TestIcebergAuthenticationFilter.java   |   1 +
 .../iceberg/service/TestIcebergRESTUtils.java      |  21 ++
 .../apache/gravitino/lance/LanceJettyServer.java   |   4 +-
 .../apache/gravitino/lance/LanceRESTService.java   |   1 +
 .../lance/service/LanceAuthenticationFilter.java   |   4 -
 .../lance/service/LanceExceptionMapper.java        |  36 ++-
 .../gravitino/lance/TestLanceRESTService.java      |  11 +
 .../service/TestLanceAuthenticationFilter.java     |   1 +
 .../lance/service/TestLanceExceptionMapper.java    |  28 ++
 ...anceMetadataAuthorizationMethodInterceptor.java |  19 ++
 .../authentication/AuthenticationFilter.java       | 193 +++++++++-----
 .../apache/gravitino/server/web/JettyServer.java   |  12 +-
 .../gravitino/server/web/JettyServerConfig.java    |  27 ++
 .../gravitino/server/web/ObjectMapperProvider.java |  83 ++++--
 .../authentication/TestAuthenticationFilter.java   | 287 ++++++++++++++++++++-
 .../TestAuthenticationOutOfMemoryHttp.java         |   2 +-
 .../gravitino/server/web/TestJettyServer.java      |  61 +++++
 .../server/web/TestJettyServerConfig.java          |  11 +
 .../apache/gravitino/server/GravitinoServer.java   |  10 +-
 .../gravitino/server/web/VersioningFilter.java     |  22 +-
 .../web/filter/GravitinoInterceptionService.java   |   3 +-
 .../gravitino/server/TestGravitinoServer.java      |  37 +++
 .../server/web/TestObjectMapperProvider.java       |  49 ++++
 .../filter/TestGravitinoInterceptionService.java   | 149 +++++++----
 37 files changed, 1016 insertions(+), 161 deletions(-)

diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
index de286ac75a..2c5ab6bcb0 100644
--- a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
+++ b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
@@ -22,6 +22,7 @@ import static 
org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -234,6 +235,33 @@ public class TestResponses {
     error.validate(); // No exception thrown
   }
 
+  @Test
+  void testThrowableErrorResponsesRetainStackTrace() throws 
IllegalArgumentException {
+    Throwable throwable = new RuntimeException("private error details");
+    String message = "public error message";
+    ErrorResponse[] responses = {
+      ErrorResponse.illegalArguments(message, throwable),
+      ErrorResponse.connectionFailed(message, throwable),
+      ErrorResponse.notFound("error type", message, throwable),
+      ErrorResponse.internalError(message, throwable),
+      ErrorResponse.alreadyExists("error type", message, throwable),
+      ErrorResponse.notInUse("error type", message, throwable),
+      ErrorResponse.inUse("error type", message, throwable),
+      ErrorResponse.nonEmpty("error type", message, throwable),
+      ErrorResponse.unsupportedOperation(message, throwable),
+      ErrorResponse.forbidden(message, throwable),
+      ErrorResponse.unauthorized("error type", message, throwable)
+    };
+
+    for (ErrorResponse response : responses) {
+      response.validate();
+      assertEquals(message, response.getMessage());
+      assertNotNull(response.getStack());
+      assertTrue(
+          response.getStack().stream().anyMatch(line -> line.contains("private 
error details")));
+    }
+  }
+
   @Test
   void testNotFoundErrorResponse() throws IllegalArgumentException {
     ErrorResponse error = ErrorResponse.notFound("error type", "not found 
error");
diff --git a/conf/gravitino-iceberg-rest-server.conf.template 
b/conf/gravitino-iceberg-rest-server.conf.template
index 62df320496..0cd7d1e0b2 100644
--- a/conf/gravitino-iceberg-rest-server.conf.template
+++ b/conf/gravitino-iceberg-rest-server.conf.template
@@ -41,6 +41,9 @@ gravitino.iceberg-rest.threadPoolWorkQueueSize = 100
 gravitino.iceberg-rest.requestHeaderSize = 131072
 # The response header size of the built-in web server
 gravitino.iceberg-rest.responseHeaderSize = 131072
+# Whether to include server-side stack traces in HTTP error responses. Set 
this to false in new
+# deployments. It remains true by default only to preserve legacy response 
behavior.
+gravitino.iceberg-rest.includeErrorStackTrace = true
 
 # THE CONFIGURATION FOR Iceberg catalog backend
 # The Iceberg catalog backend, it's recommended to change to hive or jdbc
diff --git a/conf/gravitino-lance-rest-server.conf.template 
b/conf/gravitino-lance-rest-server.conf.template
index b8e1d0b1e1..bb1acbb3a2 100644
--- a/conf/gravitino-lance-rest-server.conf.template
+++ b/conf/gravitino-lance-rest-server.conf.template
@@ -39,6 +39,9 @@ gravitino.lance-rest.threadPoolWorkQueueSize = 100
 gravitino.lance-rest.requestHeaderSize = 131072
 # The response header size of the built-in web server
 gravitino.lance-rest.responseHeaderSize = 131072
+# Whether to include server-side stack traces in HTTP error responses. Set 
this to false in new
+# deployments. It remains true by default only to preserve legacy response 
behavior.
+gravitino.lance-rest.includeErrorStackTrace = true
 
 # THE CONFIGURATION FOR Lance namespace backend
 # The backend Lance namespace for Lance REST service, it's recommended to use 
Gravitino
diff --git a/conf/gravitino.conf.template b/conf/gravitino.conf.template
index 86bde7b784..4202d1d174 100644
--- a/conf/gravitino.conf.template
+++ b/conf/gravitino.conf.template
@@ -43,6 +43,9 @@ gravitino.server.webserver.threadPoolWorkQueueSize = 100
 gravitino.server.webserver.requestHeaderSize = 131072
 # The response header size of the built-in web server
 gravitino.server.webserver.responseHeaderSize = 131072
+# Whether to include server-side stack traces in HTTP error responses. Set 
this to false in new
+# deployments. It remains true by default only to avoid breaking legacy 
clients that expect stack.
+gravitino.server.webserver.includeErrorStackTrace = true
 # UI inactivity timeout in milliseconds. The UI environment variable
 # NEXT_PUBLIC_IDLE_TIMEOUT_MS is used when this config is not set.
 # gravitino.ui.sessionIdleTimeoutMs = 900000
diff --git a/dev/docker/gravitino/rewrite_gravitino_server_config.py 
b/dev/docker/gravitino/rewrite_gravitino_server_config.py
index c5aca5d72c..9884b69b86 100755
--- a/dev/docker/gravitino/rewrite_gravitino_server_config.py
+++ b/dev/docker/gravitino/rewrite_gravitino_server_config.py
@@ -29,6 +29,7 @@ env_map = {
     "GRAVITINO_SERVER_WEBSERVER_REQUEST_HEADER_SIZE": 
"server.webserver.requestHeaderSize",
     "GRAVITINO_SERVER_WEBSERVER_RESPONSE_HEADER_SIZE": 
"server.webserver.responseHeaderSize",
     "GRAVITINO_SERVER_BULK_MAX_ITEMS": "server.bulk.maxItems",
+    "GRAVITINO_SERVER_WEBSERVER_INCLUDE_ERROR_STACK_TRACE": 
"server.webserver.includeErrorStackTrace",
     "GRAVITINO_ENTITY_STORE": "entity.store",
     "GRAVITINO_ENTITY_STORE_RELATIONAL": "entity.store.relational",
     "GRAVITINO_ENTITY_STORE_RELATIONAL_JDBC_URL": 
"entity.store.relational.jdbcUrl",
@@ -91,6 +92,7 @@ init_config = {
     "server.webserver.requestHeaderSize": "131072",
     "server.webserver.responseHeaderSize": "131072",
     "server.bulk.maxItems": "100",
+    "server.webserver.includeErrorStackTrace": "true",
     "entity.store": "relational",
     "entity.store.relational": "JDBCBackend",
     "entity.store.relational.jdbcUrl": "jdbc:h2",
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 9a39b1a613..d8421e6f04 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -173,6 +173,7 @@ empty string or list; `(none)` means it has no default at 
all.
 | `gravitino.server.rest.extensionPackages`            | Comma-separated list 
of packages to scan for additional REST resources.                              
                                                                                
        | (empty)                                 |
 | `gravitino.server.visibleConfigs`                    | Comma-separated list 
of extra properties to expose on the unauthenticated `GET /configs` endpoint, 
on top of the fixed set it always returns. Additive, so each entry widens what 
is public. | (empty)                                 |
 | `gravitino.server.bulk.maxItems`                     | Maximum number of 
items allowed in a single bulk request.                                         
                                                                                
           | `100`                                   |
+| `gravitino.server.webserver.includeErrorStackTrace`  | Whether HTTP error 
responses include server-side stack traces. Set this to `false` in new 
deployments because responses can expose internal implementation details. It 
remains `true` by default only to avoid breaking legacy clients that expect the 
`stack` field. See [OWASP REST Security: Error 
handling](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html#error-handling)
 and [CWE-209](https://cwe.mitre.org/d [...]
 
 Filters named in `customFilters` must be standard `javax.servlet` filters. 
Pass parameters to a
 filter with properties of the form
@@ -645,6 +646,7 @@ means the property is left alone.
 | `GRAVITINO_SERVER_WEBSERVER_REQUEST_HEADER_SIZE`         | 
`gravitino.server.webserver.requestHeaderSize`       | `131072`                 
                            |
 | `GRAVITINO_SERVER_WEBSERVER_RESPONSE_HEADER_SIZE`        | 
`gravitino.server.webserver.responseHeaderSize`      | `131072`                 
                            |
 | `GRAVITINO_SERVER_BULK_MAX_ITEMS`                        | 
`gravitino.server.bulk.maxItems`                     | `100`                    
                            |
+| `GRAVITINO_SERVER_WEBSERVER_INCLUDE_ERROR_STACK_TRACE`    | 
`gravitino.server.webserver.includeErrorStackTrace`  | `true`                   
                            |
 | `GRAVITINO_ENTITY_STORE`                                 | 
`gravitino.entity.store`                             | `relational`             
                            |
 | `GRAVITINO_ENTITY_STORE_RELATIONAL`                      | 
`gravitino.entity.store.relational`                  | `JDBCBackend`            
                            |
 | `GRAVITINO_ENTITY_STORE_RELATIONAL_JDBC_URL`             | 
`gravitino.entity.store.relational.jdbcUrl`          | `jdbc:h2`                
                            |
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index 8eeffbcd8d..76f35650db 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -123,6 +123,7 @@ Do not add them to the standalone server configuration.
 | `gravitino.iceberg-rest.idleTimeout`             | The timeout in ms of idle 
connections.                                                                    
                                                                                
    | `30000`                                                                   
   | No       |
 | `gravitino.iceberg-rest.requestHeaderSize`       | The maximum size of an 
HTTP request.                                                                   
                                                                                
       | `131072`                                                               
      | No       |
 | `gravitino.iceberg-rest.responseHeaderSize`      | The maximum size of an 
HTTP response.                                                                  
                                                                                
       | `131072`                                                               
      | No       |
+| `gravitino.iceberg-rest.includeErrorStackTrace`  | Whether error responses 
include server-side stack traces. Set this to `false` in new deployments 
because responses can expose internal implementation details. | `true`          
                                                             | No       |
 | `gravitino.iceberg-rest.customFilters`           | Comma-separated list of 
filter class names to apply to the APIs.                                        
                                                                                
      | (none)                                                                  
     | No       |
 
 The filter in `customFilters` should be a standard javax servlet filter.
diff --git a/docs/lance-rest-service.md b/docs/lance-rest-service.md
index ca27ebb7c0..f8c6ed5b89 100644
--- a/docs/lance-rest-service.md
+++ b/docs/lance-rest-service.md
@@ -136,6 +136,7 @@ To enable the Lance REST service within Gravitino server, 
configure the followin
 | `gravitino.lance-rest.classpath`          | Classpath for Lance REST 
service, relative to Gravitino home directory       | lance-rest-server/libs  | 
Yes      |
 | `gravitino.lance-rest.httpPort`           | Port number for Lance REST 
service                                           | 9101                    | 
No       |
 | `gravitino.lance-rest.host`               | Hostname for Lance REST service  
                                            | 0.0.0.0                 | No      
 |
+| `gravitino.lance-rest.includeErrorStackTrace` | Whether error responses 
include server-side stack traces in `detail`. Set this to `false` in new 
deployments | true | No |
 | `gravitino.lance-rest.namespace-backend`  | Namespace metadata backend 
(currently only `gravitino` is supported)         | gravitino               | 
Yes      |
 | `gravitino.lance-rest.gravitino-uri`      | Gravitino server URI. Not 
required in auxiliary mode.                        | http://localhost:8090   | 
No       |
 | `gravitino.lance-rest.gravitino-metalake` | Gravitino metalake name 
(required when namespace-backend is `gravitino`)     | (none)                  
| Yes      |
@@ -189,6 +190,7 @@ Configure the service by editing 
`{GRAVITINO_HOME}/conf/gravitino-lance-rest-ser
 | `gravitino.lance-rest.gravitino-metalake` | Gravitino metalake name    | 
(none)                | Yes      |
 | `gravitino.lance-rest.httpPort`           | Service port number        | 
9101                  | No       |
 | `gravitino.lance-rest.host`               | Service hostname           | 
0.0.0.0               | No       |
+| `gravitino.lance-rest.includeErrorStackTrace` | Whether error responses 
include stack traces | true | No |
 
 :::tip
 In standalone deployments, you only need to configure 
`gravitino.lance-rest.gravitino-metalake`,
diff --git a/docs/open-api/catalogs.yaml b/docs/open-api/catalogs.yaml
index cc290f39db..695ef01737 100644
--- a/docs/open-api/catalogs.yaml
+++ b/docs/open-api/catalogs.yaml
@@ -130,9 +130,16 @@ paths:
                     description: The message of the exception
                   stack:
                     type: array
+                    deprecated: true
+                    description: >-
+                      Deprecated diagnostic stack trace. Operators should 
disable it with
+                      gravitino.server.webserver.includeErrorStackTrace=false 
because it can
+                      expose internal implementation details. It remains 
enabled by default only
+                      to avoid breaking legacy clients that expect this field. 
See
+                      [OWASP REST Security: Error 
handling](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html#error-handling)
+                      and 
[CWE-209](https://cwe.mitre.org/data/definitions/209.html).
                     items:
                       type: string
-                      description: The stack trace of the exception
               examples:
                 TestConnectionSuccess:
                   value: {
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 23d1429a8a..826dd33aa8 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -315,6 +315,14 @@ components:
           description: A human-readable message
         stack:
           type: array
+          deprecated: true
+          description: >-
+            Deprecated diagnostic stack trace. Operators should disable it with
+            gravitino.server.webserver.includeErrorStackTrace=false because it 
can expose internal
+            implementation details. It remains enabled by default only to 
avoid breaking legacy
+            clients that expect this field. See
+            [OWASP REST Security: Error 
handling](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html#error-handling)
+            and [CWE-209](https://cwe.mitre.org/data/definitions/209.html).
           items:
             type: string
 
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 3710708c57..1786ba3e0e 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -34,6 +34,7 @@ import 
org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
 import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
 import org.apache.gravitino.iceberg.service.IcebergHealthCheckPathMatcher;
 import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
+import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupJobStore;
 import org.apache.gravitino.iceberg.service.cleanup.IcebergCleanupManager;
@@ -91,10 +92,14 @@ public class RESTService implements 
GravitinoAuxiliaryService {
 
   private void initServer(IcebergConfig icebergConfig) {
     JettyServerConfig serverConfig = 
JettyServerConfig.fromConfig(icebergConfig);
+    
IcebergRESTUtils.setIncludeErrorStackTrace(serverConfig.isIncludeErrorStackTrace());
     server =
         new JettyServer() {
           @Override
-          protected javax.servlet.Filter createAuthenticationFilter() {
+          protected javax.servlet.Filter createAuthenticationFilter(
+              boolean includeErrorStackTrace) {
+            // Iceberg authentication errors never carry a stack trace (see
+            // TestIcebergAuthenticationFilter), so either setting is honored.
             return new IcebergAuthenticationFilter();
           }
         };
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index a61a92e391..d88f4de9dd 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -49,6 +49,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.credential.Credential;
 import org.apache.gravitino.credential.CredentialPropertyUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
+import org.apache.gravitino.server.web.JettyServerConfig;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -92,6 +93,11 @@ public class IcebergRESTUtils {
           "gcs.oauth2.refresh-credentials-endpoint",
           "adls.refresh-credentials-endpoint");
 
+  // Set once at service startup. Only Iceberg REST reads this flag, and its 
classes are packaged
+  // outside the main server's classpath, so each service loads its own copy.
+  private static volatile boolean includeErrorStackTrace =
+      JettyServerConfig.INCLUDE_ERROR_STACK_TRACE.getDefaultValue();
+
   /** Snapshot modes for the Iceberg loadTable endpoint. */
   public enum SnapshotMode {
     ALL(SNAPSHOT_ALL),
@@ -425,14 +431,26 @@ public class IcebergRESTUtils {
     return Response.status(Status.NOT_FOUND).build();
   }
 
+  /**
+   * Sets whether error responses built by {@link #errorResponse(Throwable, 
int)} include the
+   * exception's stack trace. Called once when the service starts.
+   *
+   * @param include whether to include stack traces
+   */
+  public static void setIncludeErrorStackTrace(boolean include) {
+    includeErrorStackTrace = include;
+  }
+
   public static Response errorResponse(Throwable ex, int httpStatus) {
-    ErrorResponse errorResponse =
+    ErrorResponse.Builder builder =
         ErrorResponse.builder()
             .responseCode(httpStatus)
             .withType(ex.getClass().getSimpleName())
-            .withMessage(ex.getMessage())
-            .withStackTrace(ex)
-            .build();
+            .withMessage(ex.getMessage());
+    if (includeErrorStackTrace) {
+      builder.withStackTrace(ex);
+    }
+    ErrorResponse errorResponse = builder.build();
     return Response.status(httpStatus)
         .entity(errorResponse)
         .type(MediaType.APPLICATION_JSON)
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
index 36b2b5e6bd..51f4a1b8c3 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/TestRESTService.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.iceberg;
 
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Collections;
@@ -74,4 +75,14 @@ public class TestRESTService {
       server.stop();
     }
   }
+
+  /** The documented Iceberg REST key reaches the value the service passes to 
its error builder. */
+  @Test
+  public void testIncludeErrorStackTraceReadFromServiceConfig() {
+    assertTrue(JettyServerConfig.fromConfig(new 
IcebergConfig()).isIncludeErrorStackTrace());
+    assertFalse(
+        JettyServerConfig.fromConfig(
+                new 
IcebergConfig(Collections.singletonMap("includeErrorStackTrace", "false")))
+            .isIncludeErrorStackTrace());
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
index 4dbd5e1292..1abcb6ede0 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
@@ -146,6 +146,7 @@ public class TestIcebergAuthenticationFilter {
     Assertions.assertEquals(500, errorResponse.code());
     Assertions.assertEquals("ServiceFailureException", errorResponse.type());
     Assertions.assertEquals("Something went wrong", errorResponse.message());
+    Assertions.assertFalse(json.contains("\"stack\""), json);
   }
 
   @Test
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
index e3e4f3225d..af01018630 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergRESTUtils.java
@@ -48,6 +48,7 @@ import org.apache.iceberg.io.StorageCredential;
 import org.apache.iceberg.io.SupportsStorageCredentials;
 import org.apache.iceberg.rest.credentials.Credential;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -449,4 +450,24 @@ public class TestIcebergRESTUtils {
         "v1/irc1/namespaces/db/tables/tbl/credentials",
         
rewritten.credentials().get(0).config().get("client.refresh-credentials-endpoint"));
   }
+
+  @Test
+  void testErrorResponseStackTraceFollowsSetting() throws Exception {
+    RuntimeException failure = new RuntimeException("failure");
+    try {
+      ErrorResponse withStack =
+          (ErrorResponse) IcebergRESTUtils.errorResponse(failure, 
500).getEntity();
+      Assertions.assertTrue(
+          
IcebergObjectMapper.getInstance().writeValueAsString(withStack).contains("\"stack\""));
+
+      IcebergRESTUtils.setIncludeErrorStackTrace(false);
+      ErrorResponse withoutStack =
+          (ErrorResponse) IcebergRESTUtils.errorResponse(failure, 
500).getEntity();
+      String json = 
IcebergObjectMapper.getInstance().writeValueAsString(withoutStack);
+      Assertions.assertFalse(json.contains("\"stack\""), json);
+      Assertions.assertEquals("failure", withoutStack.message());
+    } finally {
+      IcebergRESTUtils.setIncludeErrorStackTrace(true);
+    }
+  }
 }
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceJettyServer.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceJettyServer.java
index be8b8dbff5..4261dd15f7 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceJettyServer.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceJettyServer.java
@@ -29,7 +29,9 @@ import org.apache.gravitino.server.web.JettyServer;
 class LanceJettyServer extends JettyServer {
 
   @Override
-  protected Filter createAuthenticationFilter() {
+  protected Filter createAuthenticationFilter(boolean includeErrorStackTrace) {
+    // Lance authentication errors always send an empty detail (see
+    // TestLanceAuthenticationFilter), so either setting is honored.
     return new LanceAuthenticationFilter();
   }
 }
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
index bfb27e04d6..59b8a3afbd 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
@@ -80,6 +80,7 @@ public class LanceRESTService implements 
GravitinoAuxiliaryService {
   public void serviceInit(Map<String, String> properties, boolean auxMode) {
     LanceConfig lanceConfig = new LanceConfig(new HashMap<>(properties));
     JettyServerConfig serverConfig = JettyServerConfig.fromConfig(lanceConfig);
+    
LanceExceptionMapper.setIncludeErrorStackTrace(serverConfig.isIncludeErrorStackTrace());
 
     server = new LanceJettyServer();
     // Get MetricsSystem and EventBus from GravitinoEnv once at init time.
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceAuthenticationFilter.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceAuthenticationFilter.java
index 45cebe3852..235ce41094 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceAuthenticationFilter.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceAuthenticationFilter.java
@@ -28,8 +28,6 @@ import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.gravitino.server.authentication.AuthenticationFilter;
 import org.apache.gravitino.server.web.ObjectMapperProvider;
 import org.lance.namespace.model.ErrorResponse;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * An {@link AuthenticationFilter} subclass for the Lance REST server that:
@@ -43,7 +41,6 @@ import org.slf4j.LoggerFactory;
  */
 public class LanceAuthenticationFilter extends AuthenticationFilter {
 
-  private static final Logger LOG = 
LoggerFactory.getLogger(LanceAuthenticationFilter.class);
   private static final ObjectMapper MAPPER = 
ObjectMapperProvider.objectMapper();
 
   public LanceAuthenticationFilter() {
@@ -75,7 +72,6 @@ public class LanceAuthenticationFilter extends 
AuthenticationFilter {
       }
     } else {
       status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-      LOG.error("Authentication failure", exception);
       message = "Authentication failed";
     }
 
diff --git 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
index a56ee9bc3b..daad886540 100644
--- 
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
+++ 
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
 import org.apache.gravitino.exceptions.NotFoundException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.gravitino.server.web.JettyServerConfig;
 import org.apache.gravitino.server.web.ServerHealth;
 import org.lance.namespace.errors.ConcurrentModificationException;
 import org.lance.namespace.errors.InternalException;
@@ -49,6 +50,31 @@ public class LanceExceptionMapper implements 
ExceptionMapper<Throwable> {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(LanceExceptionMapper.class);
 
+  // Set once at service startup. Only Lance REST reads this flag, and its 
classes are packaged
+  // outside the main server's classpath, so each service loads its own copy.
+  private static volatile boolean includeErrorStackTrace =
+      JettyServerConfig.INCLUDE_ERROR_STACK_TRACE.getDefaultValue();
+
+  /**
+   * Sets whether error details built by {@link #errorDetail(Throwable)} 
include the exception's
+   * stack trace. Called once when the service starts.
+   *
+   * @param include whether to include stack traces
+   */
+  public static void setIncludeErrorStackTrace(boolean include) {
+    includeErrorStackTrace = include;
+  }
+
+  /**
+   * Returns the error detail for a Lance error response.
+   *
+   * @param ex the failure to describe
+   * @return the stack trace of {@code ex}, or an empty string when stack 
traces are disabled
+   */
+  public static String errorDetail(Throwable ex) {
+    return includeErrorStackTrace ? getStackTrace(ex) : "";
+  }
+
   public static Response toRESTResponse(String instance, Throwable ex) {
     ServerHealth.getInstance().recordFailure(ex);
     LanceNamespaceException lanceException =
@@ -77,20 +103,20 @@ public class LanceExceptionMapper implements 
ExceptionMapper<Throwable> {
       return new UnauthenticatedException(ex.getMessage(), "", instance);
 
     } else if (ex instanceof NoSuchTableException) {
-      return new TableNotFoundException(ex.getMessage(), getStackTrace(ex), 
instance);
+      return new TableNotFoundException(ex.getMessage(), errorDetail(ex), 
instance);
 
     } else if (ex instanceof NotFoundException) {
-      return new NamespaceNotFoundException(ex.getMessage(), 
getStackTrace(ex), instance);
+      return new NamespaceNotFoundException(ex.getMessage(), errorDetail(ex), 
instance);
 
     } else if (ex instanceof IllegalArgumentException) {
-      return new InvalidInputException(ex.getMessage(), getStackTrace(ex), 
instance);
+      return new InvalidInputException(ex.getMessage(), errorDetail(ex), 
instance);
 
     } else if (ex instanceof 
org.apache.gravitino.exceptions.TableAlreadyExistsException) {
-      return new TableAlreadyExistsException(ex.getMessage(), 
getStackTrace(ex), instance);
+      return new TableAlreadyExistsException(ex.getMessage(), errorDetail(ex), 
instance);
 
     } else if (ex instanceof UnsupportedOperationException) {
       return new org.lance.namespace.errors.UnsupportedOperationException(
-          ex.getMessage(), getStackTrace(ex), instance);
+          ex.getMessage(), errorDetail(ex), instance);
 
     } else {
       LOG.warn("Lance REST server unexpected exception:", ex);
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
index bda74859f0..4798ea1f55 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/TestLanceRESTService.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.lance;
 
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Collections;
@@ -74,4 +75,14 @@ public class TestLanceRESTService {
       server.stop();
     }
   }
+
+  /** The documented Lance REST key reaches the value the service passes to 
its error builder. */
+  @Test
+  public void testIncludeErrorStackTraceReadFromServiceConfig() {
+    assertTrue(JettyServerConfig.fromConfig(new 
LanceConfig()).isIncludeErrorStackTrace());
+    assertFalse(
+        JettyServerConfig.fromConfig(
+                new 
LanceConfig(Collections.singletonMap("includeErrorStackTrace", "false")))
+            .isIncludeErrorStackTrace());
+  }
 }
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceAuthenticationFilter.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceAuthenticationFilter.java
index 0b73c7ae7f..69d581597f 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceAuthenticationFilter.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceAuthenticationFilter.java
@@ -153,5 +153,6 @@ public class TestLanceAuthenticationFilter {
     ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
     Assertions.assertEquals(500, errorResponse.getCode());
     Assertions.assertEquals("Authentication failed", errorResponse.getError());
+    Assertions.assertEquals("", errorResponse.getDetail());
   }
 }
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java
index 8f4513fdf6..42c4faac2f 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java
@@ -135,4 +135,32 @@ public class TestLanceExceptionMapper extends JerseyTest {
       Assertions.assertEquals("catalog", error.getInstance());
     }
   }
+
+  /** Verifies that the error detail omits the stack trace when disabled. */
+  @Test
+  public void testErrorDetailFollowsSetting() {
+    IllegalArgumentException failure = new IllegalArgumentException("failure");
+    try {
+      Assertions.assertTrue(
+          LanceExceptionMapper.errorDetail(failure)
+              .contains("java.lang.IllegalArgumentException: failure"));
+
+      LanceExceptionMapper.setIncludeErrorStackTrace(false);
+      Assertions.assertEquals("", LanceExceptionMapper.errorDetail(failure));
+      Response response = LanceExceptionMapper.toRESTResponse("", failure);
+      Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      ErrorResponse entity = (ErrorResponse) response.getEntity();
+      Assertions.assertEquals("failure", entity.getError());
+      Assertions.assertEquals("", entity.getDetail());
+
+      // Lance exceptions that already carry a detail keep it; only generated 
stacks are omitted.
+      Response nativeResponse =
+          LanceExceptionMapper.toRESTResponse(
+              "instance", new InvalidInputException("bad input", "native 
detail", "instance"));
+      Assertions.assertEquals(
+          "native detail", ((ErrorResponse) 
nativeResponse.getEntity()).getDetail());
+    } finally {
+      LanceExceptionMapper.setIncludeErrorStackTrace(true);
+    }
+  }
 }
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
index 517d74d770..5c30b511cd 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/authorization/TestLanceMetadataAuthorizationMethodInterceptor.java
@@ -40,6 +40,7 @@ import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.lance.service.LanceExceptionMapper;
 import 
org.apache.gravitino.lance.service.authorization.annotations.LanceRootNamespace;
 import org.apache.gravitino.lance.service.rest.LanceNamespaceOperations;
 import org.apache.gravitino.lance.service.rest.LanceTableOperations;
@@ -122,6 +123,24 @@ class TestLanceMetadataAuthorizationMethodInterceptor {
     assertErrorResponse(describe, Response.Status.FORBIDDEN);
   }
 
+  @Test
+  void testForbiddenResponseOmitsStackTraceWhenDisabled() throws Throwable {
+    allow(Privilege.Name.USE_CATALOG, Privilege.Name.CREATE_SCHEMA);
+    when(authorizer.deny(any(), any(), any(), any(), any())).thenReturn(true);
+    try {
+      LanceExceptionMapper.setIncludeErrorStackTrace(false);
+      Object result =
+          interceptor.invoke(
+              invocation(namespaceMethod("namespaceExists"), CATALOG + "$" + 
SCHEMA, "$"));
+
+      assertErrorResponse(result, Response.Status.FORBIDDEN);
+      ErrorResponse entity = (ErrorResponse) ((Response) result).getEntity();
+      assertEquals("", entity.getDetail());
+    } finally {
+      LanceExceptionMapper.setIncludeErrorStackTrace(true);
+    }
+  }
+
   @Test
   void testDenyOverridesSchemaProbePrivileges() throws Throwable {
     allow(Privilege.Name.USE_CATALOG, Privilege.Name.CREATE_SCHEMA);
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
index 28024d6075..74eca33ed4 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.server.authentication;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -55,6 +56,8 @@ public class AuthenticationFilter implements Filter {
 
   private final List<Authenticator> filterAuthenticators;
 
+  private final ObjectMapper objectMapper;
+
   /**
    * The matcher used to identify health check paths that bypass 
authentication. Subclasses may
    * replace this with a server-specific matcher (e.g. {@code 
IcebergHealthCheckPathMatcher}).
@@ -62,12 +65,32 @@ public class AuthenticationFilter implements Filter {
   protected HealthCheckPathMatcher healthCheckMatcher = new 
HealthCheckPathMatcher();
 
   public AuthenticationFilter() {
-    filterAuthenticators = null;
+    this(null, ObjectMapperProvider.objectMapper());
+  }
+
+  /**
+   * Creates an authentication filter with explicit error stack-trace response 
behavior.
+   *
+   * @param includeErrorStackTrace whether authentication error responses 
should include diagnostic
+   *     stack traces
+   */
+  public AuthenticationFilter(boolean includeErrorStackTrace) {
+    this(null, ObjectMapperProvider.objectMapper(includeErrorStackTrace));
   }
 
   @VisibleForTesting
   AuthenticationFilter(List<Authenticator> authenticators) {
+    this(authenticators, ObjectMapperProvider.objectMapper());
+  }
+
+  @VisibleForTesting
+  AuthenticationFilter(List<Authenticator> authenticators, boolean 
includeErrorStackTrace) {
+    this(authenticators, 
ObjectMapperProvider.objectMapper(includeErrorStackTrace));
+  }
+
+  private AuthenticationFilter(List<Authenticator> authenticators, 
ObjectMapper objectMapper) {
     this.filterAuthenticators = authenticators;
+    this.objectMapper = objectMapper;
   }
 
   @Override
@@ -83,74 +106,26 @@ public class AuthenticationFilter implements Filter {
       chain.doFilter(request, response);
       return;
     }
+    HttpServletRequest req = (HttpServletRequest) request;
+    HttpServletResponse resp = (HttpServletResponse) response;
     try {
-      List<Authenticator> authenticators;
-      if (filterAuthenticators == null || filterAuthenticators.isEmpty()) {
-        authenticators = ServerAuthenticator.getInstance().authenticators();
-      } else {
-        authenticators = filterAuthenticators;
-      }
-      HttpServletRequest req = (HttpServletRequest) request;
-      Enumeration<String> headerData = 
req.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION);
-      byte[] authData = null;
-      if (headerData.hasMoreElements()) {
-        authData = headerData.nextElement().getBytes(StandardCharsets.UTF_8);
-      }
-
-      // If token is supported by multiple authenticators, use the first by 
default.
-      Principal principal = null;
-      for (Authenticator authenticator : authenticators) {
-        if (authenticator.supportsToken(authData) && 
authenticator.isDataFromToken()) {
-          principal = authenticator.authenticateToken(authData);
-          if (principal != null) {
-            break;
-          }
-        }
-      }
-      if (LOG.isDebugEnabled()) {
-        LOG.debug(
-            "uri={} hasAuthHeader={} principal={}",
-            req.getRequestURI(),
-            authData != null,
-            principal == null ? "null" : principal.getName());
-      }
-      if (principal == null) {
-        throw new UnauthorizedException("The provided credentials did not 
support");
-      }
-      // Role assumption: parse the header (syntactic only; malformed -> 400) 
and, only when
-      // narrowed, attach the roles to the principal. Membership 403 is 
checked later.
-      ActiveRoles activeRoles =
-          
ActiveRolesParser.parse(req.getHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER));
-      if (!activeRoles.isAll() && principal instanceof UserPrincipal) {
-        principal = ((UserPrincipal) principal).withActiveRoles(activeRoles);
-      }
-      // Publish the finalized principal (already carrying any narrowed roles) 
so downstream
-      // re-binds from the attribute (e.g. Utils.doAs) see the same identity 
and roles.
-      
request.setAttribute(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME, 
principal);
-      PrincipalUtils.doAs(
-          principal,
-          () -> {
-            chain.doFilter(request, response);
-            return null;
-          });
+      Principal principal = authenticate(req);
+      runAsPrincipal(principal, req, resp, chain);
     } catch (UnauthorizedException ue) {
       health.recordFailure(ue);
-      HttpServletResponse resp = (HttpServletResponse) response;
-      if (!ue.getChallenges().isEmpty()) {
-        // For some authentication, HTTP response can provide some challenge 
information
-        // to let client to create correct authenticated request.
-        // Refer to 
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate
-        for (String challenge : ue.getChallenges()) {
-          if (!challenge.toLowerCase().startsWith("basic")) {
-            resp.setHeader(AuthConstants.HTTP_CHALLENGE_HEADER, challenge);
-          }
-        }
-      }
-      sendAuthErrorResponse(resp, ue);
-    } catch (Exception e) {
-      health.recordFailure(e);
-      HttpServletResponse resp = (HttpServletResponse) response;
-      sendAuthErrorResponse(resp, e);
+      sendUnauthorizedResponse(resp, ue);
+    } catch (IllegalActiveRolesException | ForbiddenException clientError) {
+      health.recordFailure(clientError);
+      sendAuthErrorResponse(resp, clientError);
+    } catch (RuntimeException unexpected) {
+      health.recordFailure(unexpected);
+      // The response may omit the stack trace, so keep the cause in the 
server log.
+      LOG.error("Unexpected error while processing request to {}", 
req.getRequestURI(), unexpected);
+      sendAuthErrorResponse(resp, unexpected);
+    } catch (Exception checked) {
+      health.recordFailure(checked);
+      // Only the downstream chain throws checked exceptions, and 
PrincipalUtils.doAs logs them.
+      sendAuthErrorResponse(resp, checked);
     }
   }
 
@@ -188,7 +163,7 @@ public class AuthenticationFilter implements Filter {
     response.setStatus(httpStatus);
     response.setContentType("application/json");
     response.setCharacterEncoding(StandardCharsets.UTF_8.name());
-    ObjectMapperProvider.objectMapper().writeValue(response.getWriter(), 
errorResponse);
+    objectMapper.writeValue(response.getWriter(), errorResponse);
   }
 
   /**
@@ -207,4 +182,88 @@ public class AuthenticationFilter implements Filter {
 
   @Override
   public void destroy() {}
+
+  private Principal authenticate(HttpServletRequest request) {
+    return applyActiveRoles(request, resolvePrincipal(request));
+  }
+
+  private Principal resolvePrincipal(HttpServletRequest request) {
+    byte[] authData = authorizationData(request);
+    Principal principal = null;
+    // If token is supported by multiple authenticators, use the first by 
default.
+    for (Authenticator authenticator : authenticators()) {
+      if (authenticator.supportsToken(authData) && 
authenticator.isDataFromToken()) {
+        principal = authenticator.authenticateToken(authData);
+        if (principal != null) {
+          break;
+        }
+      }
+    }
+    if (LOG.isDebugEnabled()) {
+      LOG.debug(
+          "uri={} hasAuthHeader={} principal={}",
+          request.getRequestURI(),
+          authData != null,
+          principal == null ? "null" : principal.getName());
+    }
+    if (principal == null) {
+      throw new UnauthorizedException("The provided credentials did not 
support");
+    }
+    return principal;
+  }
+
+  private List<Authenticator> authenticators() {
+    if (filterAuthenticators == null || filterAuthenticators.isEmpty()) {
+      return ServerAuthenticator.getInstance().authenticators();
+    }
+    return filterAuthenticators;
+  }
+
+  private static byte[] authorizationData(HttpServletRequest request) {
+    Enumeration<String> headerData = 
request.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION);
+    return headerData.hasMoreElements()
+        ? headerData.nextElement().getBytes(StandardCharsets.UTF_8)
+        : null;
+  }
+
+  // Role assumption: parse the header (syntactic only; malformed -> 400) and, 
only when narrowed,
+  // attach the roles to the principal. Membership 403 is checked later.
+  private static Principal applyActiveRoles(HttpServletRequest request, 
Principal principal) {
+    ActiveRoles activeRoles =
+        
ActiveRolesParser.parse(request.getHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER));
+    if (!activeRoles.isAll() && principal instanceof UserPrincipal) {
+      return ((UserPrincipal) principal).withActiveRoles(activeRoles);
+    }
+    return principal;
+  }
+
+  private static void runAsPrincipal(
+      Principal principal,
+      HttpServletRequest request,
+      HttpServletResponse response,
+      FilterChain chain)
+      throws Exception {
+    // Publish the finalized principal (already carrying any narrowed roles) 
so downstream
+    // re-binds from the attribute (e.g. Utils.doAs) see the same identity and 
roles.
+    request.setAttribute(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME, 
principal);
+    PrincipalUtils.doAs(
+        principal,
+        () -> {
+          chain.doFilter(request, response);
+          return null;
+        });
+  }
+
+  private void sendUnauthorizedResponse(HttpServletResponse response, 
UnauthorizedException ue)
+      throws IOException {
+    // For some authentication, HTTP response can provide some challenge 
information
+    // to let client to create correct authenticated request.
+    // Refer to 
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate
+    for (String challenge : ue.getChallenges()) {
+      if (!challenge.toLowerCase().startsWith("basic")) {
+        response.setHeader(AuthConstants.HTTP_CHALLENGE_HEADER, challenge);
+      }
+    }
+    sendAuthErrorResponse(response, ue);
+  }
 }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
index 7a507eb74c..7f0dea29e1 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
@@ -116,7 +116,7 @@ public class JettyServer {
 
     // Set error handler for Jetty Server
     ErrorHandler errorHandler = new ErrorHandler();
-    errorHandler.setShowStacks(true);
+    errorHandler.setShowStacks(serverConfig.isIncludeErrorStackTrace());
     errorHandler.setServer(server);
     server.addBean(errorHandler);
 
@@ -538,14 +538,18 @@ public class JettyServer {
       servletContextHandler.addFilter(
           CorsFilterHolder.create(serverConfig), pathSpec, 
EnumSet.allOf(DispatcherType.class));
     }
-    addFilter(createAuthenticationFilter(), pathSpec);
+    
addFilter(createAuthenticationFilter(serverConfig.isIncludeErrorStackTrace()), 
pathSpec);
   }
 
   /**
    * Creates the authentication filter for this server. Subclasses can 
override this to provide a
    * custom authentication filter (e.g., one that returns Iceberg-spec JSON 
error responses).
+   *
+   * @param includeErrorStackTrace whether the filter's error responses may 
include server-side
+   *     stack traces; implementations must not include them when this is 
{@code false}
+   * @return the authentication filter
    */
-  protected Filter createAuthenticationFilter() {
-    return new AuthenticationFilter();
+  protected Filter createAuthenticationFilter(boolean includeErrorStackTrace) {
+    return new AuthenticationFilter(includeErrorStackTrace);
   }
 }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServerConfig.java
 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServerConfig.java
index cb7a5b27e6..825716faa3 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServerConfig.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServerConfig.java
@@ -112,6 +112,21 @@ public final class JettyServerConfig {
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
           .createWithDefault(128 * 1024);
 
+  /**
+   * Whether HTTP error responses include server-side stack traces. Defaults 
to {@code true} for
+   * compatibility with clients that read the {@code stack} field.
+   */
+  public static final ConfigEntry<Boolean> INCLUDE_ERROR_STACK_TRACE =
+      new ConfigBuilder("includeErrorStackTrace")
+          .doc(
+              "Whether to include server-side stack traces in HTTP error 
responses. Set this to "
+                  + "false in new deployments because stack traces can expose 
internal "
+                  + "implementation details. It remains true by default only 
to avoid breaking "
+                  + "legacy clients that expect the stack field")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .booleanConf()
+          .createWithDefault(true);
+
   public static final ConfigEntry<Integer> 
WEBSERVER_THREAD_POOL_WORK_QUEUE_SIZE =
       new ConfigBuilder("threadPoolWorkQueueSize")
           .doc("The size of the queue in the thread pool used by Jetty 
webserver")
@@ -313,6 +328,8 @@ public final class JettyServerConfig {
 
   private final int responseHeaderSize;
 
+  private final boolean includeErrorStackTrace;
+
   private final int threadPoolWorkQueueSize;
 
   private final int httpsPort;
@@ -368,6 +385,7 @@ public final class JettyServerConfig {
     this.idleTimeout = internalConfig.get(WEBSERVER_IDLE_TIMEOUT);
     this.requestHeaderSize = internalConfig.get(WEBSERVER_REQUEST_HEADER_SIZE);
     this.responseHeaderSize = 
internalConfig.get(WEBSERVER_RESPONSE_HEADER_SIZE);
+    this.includeErrorStackTrace = 
internalConfig.get(INCLUDE_ERROR_STACK_TRACE);
     this.threadPoolWorkQueueSize = 
internalConfig.get(WEBSERVER_THREAD_POOL_WORK_QUEUE_SIZE);
 
     this.enableHttps = internalConfig.get(ENABLE_HTTPS);
@@ -456,6 +474,15 @@ public final class JettyServerConfig {
     return responseHeaderSize;
   }
 
+  /**
+   * Returns whether HTTP error responses include server-side stack traces.
+   *
+   * @return {@code true} if HTTP error responses include server-side stack 
traces
+   */
+  public boolean isIncludeErrorStackTrace() {
+    return includeErrorStackTrace;
+  }
+
   public int getThreadPoolWorkQueueSize() {
     return threadPoolWorkQueueSize;
   }
diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/web/ObjectMapperProvider.java
 
b/server-common/src/main/java/org/apache/gravitino/server/web/ObjectMapperProvider.java
index fdf8ac9115..0430824fd8 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/web/ObjectMapperProvider.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/web/ObjectMapperProvider.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.server.web;
 
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.databind.MapperFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -28,38 +29,88 @@ import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
 import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import javax.ws.rs.ext.ContextResolver;
 import javax.ws.rs.ext.Provider;
+import org.apache.gravitino.dto.responses.ErrorResponse;
 
 @Provider
 public class ObjectMapperProvider implements ContextResolver<ObjectMapper> {
 
+  // Keep diagnostic stacks inside the server and accept legacy payloads while 
allowing operators
+  // to omit them from responses.
+  @JsonIgnoreProperties(value = "stack", allowSetters = true)
+  private abstract static class ErrorResponseMixin {}
+
   private static class ObjectMapperHolder {
-    private static final ObjectMapper INSTANCE =
-        JsonMapper.builder()
-            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
-            .configure(EnumFeature.WRITE_ENUMS_TO_LOWERCASE, true)
-            .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
-            .build()
-            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
-            .registerModule(new JavaTimeModule())
-            .registerModule(new Jdk8Module());
+    private static final ObjectMapper WITHOUT_ERROR_STACK_TRACE = 
createObjectMapper(false);
+    private static final ObjectMapper WITH_ERROR_STACK_TRACE = 
createObjectMapper(true);
+  }
+
+  private final ObjectMapper objectMapper;
+
+  /**
+   * Creates a provider using the backward-compatible server default, which 
includes error stack
+   * traces.
+   */
+  public ObjectMapperProvider() {
+    this.objectMapper = objectMapper();
+  }
+
+  /**
+   * Creates a provider with explicit error stack-trace serialization behavior.
+   *
+   * @param includeErrorStackTrace whether HTTP error responses should include 
diagnostic stack
+   *     traces
+   */
+  public ObjectMapperProvider(boolean includeErrorStackTrace) {
+    this.objectMapper = objectMapper(includeErrorStackTrace);
   }
 
   /**
-   * Retrieves a globally shared {@link ObjectMapper} instance.
+   * Retrieves the shared {@link ObjectMapper} using the backward-compatible 
server default.
    *
-   * <p>Note: This ObjectMapper is a global single instance. If you need to 
modify the default
-   * serialization/deserialization settings, make changes within the INSTANCE 
builder directly.
-   * Avoid modifying properties of the returned {@code ObjectMapper} instance 
to prevent unintended
-   * side effects.
+   * <p>Do not modify the returned mapper. Use {@link #objectMapper(boolean)} 
to select explicit
+   * error stack-trace behavior.
    *
    * @return the globally shared {@link ObjectMapper} instance
    */
   public static ObjectMapper objectMapper() {
-    return ObjectMapperHolder.INSTANCE;
+    return 
objectMapper(JettyServerConfig.INCLUDE_ERROR_STACK_TRACE.getDefaultValue());
   }
 
   @Override
   public ObjectMapper getContext(Class<?> type) {
-    return ObjectMapperHolder.INSTANCE;
+    return objectMapper;
+  }
+
+  /**
+   * Retrieves a shared, preconfigured mapper with explicit error stack-trace 
serialization
+   * behavior.
+   *
+   * <p>Do not modify the returned mapper.
+   *
+   * @param includeErrorStackTrace whether HTTP error responses should include 
diagnostic stack
+   *     traces
+   * @return a shared {@link ObjectMapper} with the requested behavior
+   */
+  public static ObjectMapper objectMapper(boolean includeErrorStackTrace) {
+    return includeErrorStackTrace
+        ? ObjectMapperHolder.WITH_ERROR_STACK_TRACE
+        : ObjectMapperHolder.WITHOUT_ERROR_STACK_TRACE;
+  }
+
+  private static ObjectMapper createObjectMapper(boolean 
includeErrorStackTrace) {
+    JsonMapper.Builder builder =
+        JsonMapper.builder()
+            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+            .configure(EnumFeature.WRITE_ENUMS_TO_LOWERCASE, true)
+            .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
+    if (!includeErrorStackTrace) {
+      builder.addMixIn(ErrorResponse.class, ErrorResponseMixin.class);
+    }
+
+    return builder
+        .build()
+        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+        .registerModule(new JavaTimeModule())
+        .registerModule(new Jdk8Module());
   }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
index 8af1b58fed..7aaa9fcc99 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
@@ -22,6 +22,7 @@ package org.apache.gravitino.server.authentication;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -35,7 +36,11 @@ import java.io.StringWriter;
 import java.security.Principal;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Vector;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicReference;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -49,6 +54,14 @@ import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.gravitino.server.web.ObjectMapperProvider;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.AbstractConfiguration;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.layout.PatternLayout;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -149,7 +162,8 @@ public class TestAuthenticationFilter {
   @Test
   public void testDoFilterWithException() throws ServletException, IOException 
{
     Authenticator authenticator = mock(Authenticator.class);
-    AuthenticationFilter filter = new 
AuthenticationFilter(Lists.newArrayList(authenticator));
+    AuthenticationFilter filter =
+        new AuthenticationFilter(Lists.newArrayList(authenticator), false);
     FilterChain mockChain = mock(FilterChain.class);
     HttpServletRequest mockRequest = mock(HttpServletRequest.class);
     HttpServletResponse mockResponse = mock(HttpServletResponse.class);
@@ -169,13 +183,29 @@ public class TestAuthenticationFilter {
 
     printWriter.flush();
     String json = stringWriter.toString();
-    ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+    ObjectMapper mapper = ObjectMapperProvider.objectMapper(false);
+    Assertions.assertFalse(mapper.readTree(json).has("stack"));
     ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
     Assertions.assertEquals(1011, errorResponse.getCode());
     Assertions.assertEquals("UnauthorizedException", errorResponse.getType());
     Assertions.assertEquals("UNAUTHORIZED", errorResponse.getMessage());
   }
 
+  @Test
+  public void testAuthErrorIncludesStackWhenEnabled() throws Exception {
+    AuthenticationFilter filter = new 
AuthenticationFilter(Lists.newArrayList(), true);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    StringWriter stringWriter = new StringWriter();
+    PrintWriter printWriter = new PrintWriter(stringWriter);
+    when(response.getWriter()).thenReturn(printWriter);
+
+    filter.sendAuthErrorResponse(response, new 
UnauthorizedException("UNAUTHORIZED"));
+
+    printWriter.flush();
+    Assertions.assertTrue(
+        
ObjectMapperProvider.objectMapper(true).readTree(stringWriter.toString()).has("stack"));
+  }
+
   @Test
   public void testMultiFilterNormal() throws ServletException, IOException {
 
@@ -372,4 +402,257 @@ public class TestAuthenticationFilter {
     Assertions.assertEquals("RuntimeException", errorResponse.getType());
     Assertions.assertEquals("Something went wrong", 
errorResponse.getMessage());
   }
+
+  @Test
+  public void testUnexpectedAuthenticationErrorIsLogged() throws Exception {
+    RuntimeException failure = new RuntimeException("authenticator bug");
+    Authenticator authenticator = mock(Authenticator.class);
+    when(authenticator.supportsToken(any())).thenReturn(true);
+    when(authenticator.isDataFromToken()).thenReturn(true);
+    when(authenticator.authenticateToken(any())).thenThrow(failure);
+    AuthenticationFilter filter = new 
AuthenticationFilter(Lists.newArrayList(authenticator));
+    FilterChain mockChain = mock(FilterChain.class);
+    HttpServletRequest mockRequest = requestWithAuthorizationHeader();
+    HttpServletResponse mockResponse = responseWithWriter();
+
+    List<LogEvent> errors =
+        captureErrorLogs(() -> filter.doFilter(mockRequest, mockResponse, 
mockChain));
+
+    
verify(mockResponse).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    verify(mockChain, never()).doFilter(any(), any());
+    Assertions.assertEquals(1, errors.size());
+    Assertions.assertSame(failure, errors.get(0).getThrown());
+  }
+
+  @Test
+  public void testClientAuthenticationErrorsAreNotLogged() throws Exception {
+    Authenticator rejectingAuthenticator = mock(Authenticator.class);
+    when(rejectingAuthenticator.supportsToken(any())).thenReturn(true);
+    when(rejectingAuthenticator.isDataFromToken()).thenReturn(true);
+    when(rejectingAuthenticator.authenticateToken(any()))
+        .thenThrow(new UnauthorizedException("UNAUTHORIZED"));
+    AuthenticationFilter rejectingFilter =
+        new AuthenticationFilter(Lists.newArrayList(rejectingAuthenticator));
+    HttpServletResponse unauthorizedResponse = responseWithWriter();
+
+    Authenticator acceptingAuthenticator = mock(Authenticator.class);
+    when(acceptingAuthenticator.supportsToken(any())).thenReturn(true);
+    when(acceptingAuthenticator.isDataFromToken()).thenReturn(true);
+    when(acceptingAuthenticator.authenticateToken(any())).thenReturn(new 
UserPrincipal("user"));
+    AuthenticationFilter acceptingFilter =
+        new AuthenticationFilter(Lists.newArrayList(acceptingAuthenticator));
+    HttpServletRequest malformedRolesRequest = 
requestWithAuthorizationHeader();
+    
when(malformedRolesRequest.getHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER))
+        .thenReturn("ALL,analyst");
+    HttpServletResponse badRequestResponse = responseWithWriter();
+
+    Authenticator forbiddingAuthenticator = mock(Authenticator.class);
+    when(forbiddingAuthenticator.supportsToken(any())).thenReturn(true);
+    when(forbiddingAuthenticator.isDataFromToken()).thenReturn(true);
+    when(forbiddingAuthenticator.authenticateToken(any()))
+        .thenThrow(new ForbiddenException("Access denied"));
+    AuthenticationFilter forbiddingFilter =
+        new AuthenticationFilter(Lists.newArrayList(forbiddingAuthenticator));
+    HttpServletResponse forbiddenResponse = responseWithWriter();
+
+    List<LogEvent> errors =
+        captureErrorLogs(
+            () -> {
+              rejectingFilter.doFilter(
+                  requestWithAuthorizationHeader(), unauthorizedResponse, 
mock(FilterChain.class));
+              acceptingFilter.doFilter(
+                  malformedRolesRequest, badRequestResponse, 
mock(FilterChain.class));
+              forbiddingFilter.doFilter(
+                  requestWithAuthorizationHeader(), forbiddenResponse, 
mock(FilterChain.class));
+            });
+
+    
verify(unauthorizedResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+    verify(badRequestResponse).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+    verify(forbiddenResponse).setStatus(HttpServletResponse.SC_FORBIDDEN);
+    Assertions.assertTrue(errors.isEmpty());
+  }
+
+  @Test
+  public void testDownstreamCheckedFailureIsLoggedOnce() throws Exception {
+    ServletException failure = new ServletException("downstream failure");
+    FilterChain failingChain =
+        (req, resp) -> {
+          throw failure;
+        };
+    HttpServletResponse mockResponse = responseWithWriter();
+
+    List<LogEvent> errors =
+        captureErrorLogs(
+            () ->
+                acceptingFilter()
+                    .doFilter(requestWithAuthorizationHeader(), mockResponse, 
failingChain));
+
+    
verify(mockResponse).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    // PrincipalUtils.doAs logs checked failures, so the filter must not log 
them again.
+    Assertions.assertEquals(1, errors.size());
+    Assertions.assertEquals(PrincipalUtils.class.getName(), 
errors.get(0).getLoggerName());
+  }
+
+  @Test
+  public void testDownstreamUncheckedFailureIsLoggedOnce() throws Exception {
+    IllegalStateException failure = new IllegalStateException("downstream 
bug");
+    FilterChain failingChain =
+        (req, resp) -> {
+          throw failure;
+        };
+    HttpServletResponse mockResponse = responseWithWriter();
+
+    List<LogEvent> errors =
+        captureErrorLogs(
+            () ->
+                acceptingFilter()
+                    .doFilter(requestWithAuthorizationHeader(), mockResponse, 
failingChain));
+
+    
verify(mockResponse).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    Assertions.assertEquals(1, errors.size());
+    Assertions.assertSame(failure, errors.get(0).getThrown());
+  }
+
+  @Test
+  public void testDownstreamForbiddenIsNotLogged() throws Exception {
+    FilterChain forbiddingChain =
+        (req, resp) -> {
+          throw new ForbiddenException("Access denied");
+        };
+    HttpServletResponse mockResponse = responseWithWriter();
+
+    List<LogEvent> errors =
+        captureErrorLogs(
+            () ->
+                acceptingFilter()
+                    .doFilter(requestWithAuthorizationHeader(), mockResponse, 
forbiddingChain));
+
+    verify(mockResponse).setStatus(HttpServletResponse.SC_FORBIDDEN);
+    Assertions.assertTrue(errors.isEmpty());
+  }
+
+  @Test
+  public void testUnauthorizedResponseSetsOnlyNonBasicChallenges() throws 
Exception {
+    Authenticator negotiateAuthenticator = mock(Authenticator.class);
+    when(negotiateAuthenticator.supportsToken(any())).thenReturn(true);
+    when(negotiateAuthenticator.isDataFromToken()).thenReturn(true);
+    when(negotiateAuthenticator.authenticateToken(any()))
+        .thenThrow(new UnauthorizedException("Blank token found", 
AuthConstants.NEGOTIATE));
+    HttpServletResponse negotiateResponse = responseWithWriter();
+
+    Authenticator basicAuthenticator = mock(Authenticator.class);
+    when(basicAuthenticator.supportsToken(any())).thenReturn(true);
+    when(basicAuthenticator.isDataFromToken()).thenReturn(true);
+    when(basicAuthenticator.authenticateToken(any()))
+        .thenThrow(new UnauthorizedException("Bad credentials", "Basic 
realm=\"gravitino\""));
+    HttpServletResponse basicResponse = responseWithWriter();
+
+    new AuthenticationFilter(Lists.newArrayList(negotiateAuthenticator))
+        .doFilter(requestWithAuthorizationHeader(), negotiateResponse, 
mock(FilterChain.class));
+    new AuthenticationFilter(Lists.newArrayList(basicAuthenticator))
+        .doFilter(requestWithAuthorizationHeader(), basicResponse, 
mock(FilterChain.class));
+
+    verify(negotiateResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+    verify(negotiateResponse)
+        .setHeader(AuthConstants.HTTP_CHALLENGE_HEADER, 
AuthConstants.NEGOTIATE);
+    verify(basicResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+    verify(basicResponse, 
never()).setHeader(eq(AuthConstants.HTTP_CHALLENGE_HEADER), anyString());
+  }
+
+  @Test
+  public void testDownstreamUnauthorizedReturns401WithChallenge() throws 
Exception {
+    Authenticator authenticator = mock(Authenticator.class);
+    when(authenticator.supportsToken(any())).thenReturn(true);
+    when(authenticator.isDataFromToken()).thenReturn(true);
+    when(authenticator.authenticateToken(any())).thenReturn(new 
UserPrincipal("user"));
+    AuthenticationFilter filter = new 
AuthenticationFilter(Lists.newArrayList(authenticator));
+    HttpServletRequest mockRequest = requestWithAuthorizationHeader();
+    HttpServletResponse mockResponse = responseWithWriter();
+    FilterChain rejectingChain =
+        (req, resp) -> {
+          throw new UnauthorizedException("Token expired downstream", 
AuthConstants.NEGOTIATE);
+        };
+
+    List<LogEvent> errors =
+        captureErrorLogs(() -> filter.doFilter(mockRequest, mockResponse, 
rejectingChain));
+
+    verify(mockRequest)
+        
.setAttribute(eq(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME), any());
+    verify(mockResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+    verify(mockResponse).setHeader(AuthConstants.HTTP_CHALLENGE_HEADER, 
AuthConstants.NEGOTIATE);
+    Assertions.assertTrue(errors.isEmpty());
+  }
+
+  private static AuthenticationFilter acceptingFilter() {
+    Authenticator authenticator = mock(Authenticator.class);
+    when(authenticator.supportsToken(any())).thenReturn(true);
+    when(authenticator.isDataFromToken()).thenReturn(true);
+    when(authenticator.authenticateToken(any())).thenReturn(new 
UserPrincipal("user"));
+    return new AuthenticationFilter(Lists.newArrayList(authenticator));
+  }
+
+  private static HttpServletRequest requestWithAuthorizationHeader() {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION))
+        .thenReturn(new 
Vector<>(Collections.singletonList("user")).elements());
+    return request;
+  }
+
+  private static HttpServletResponse responseWithWriter() throws IOException {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+    return response;
+  }
+
+  private interface FilterInvocation {
+    void run() throws Exception;
+  }
+
+  /**
+   * Runs the invocation and returns the ERROR events logged by {@link 
AuthenticationFilter} and
+   * {@link PrincipalUtils}, the two places a failure on the authentication 
path can be logged.
+   */
+  private static List<LogEvent> captureErrorLogs(FilterInvocation invocation) 
throws Exception {
+    List<String> loggerNames =
+        Arrays.asList(AuthenticationFilter.class.getName(), 
PrincipalUtils.class.getName());
+    LoggerContext loggerContext =
+        (LoggerContext) 
LogManager.getContext(AuthenticationFilter.class.getClassLoader(), false);
+    AbstractConfiguration configuration = (AbstractConfiguration) 
loggerContext.getConfiguration();
+    Map<String, LoggerConfig> previousLoggerConfigs = new HashMap<>();
+    for (String loggerName : loggerNames) {
+      previousLoggerConfigs.put(loggerName, 
configuration.getLoggers().get(loggerName));
+    }
+    List<LogEvent> events = new CopyOnWriteArrayList<>();
+    AbstractAppender appender =
+        new AbstractAppender(
+            "authenticationFilterCapture", null, 
PatternLayout.createDefaultLayout(), true, null) {
+          @Override
+          public void append(LogEvent event) {
+            events.add(event.toImmutable());
+          }
+        };
+    try {
+      appender.start();
+      configuration.addAppender(appender);
+      for (String loggerName : loggerNames) {
+        LoggerConfig loggerConfig = new LoggerConfig(loggerName, Level.ERROR, 
false);
+        loggerConfig.addAppender(appender, Level.ERROR, null);
+        configuration.addLogger(loggerName, loggerConfig);
+      }
+      loggerContext.updateLoggers();
+      invocation.run();
+    } finally {
+      for (String loggerName : loggerNames) {
+        configuration.removeLogger(loggerName);
+        LoggerConfig previousLoggerConfig = 
previousLoggerConfigs.get(loggerName);
+        if (previousLoggerConfig != null) {
+          configuration.addLogger(loggerName, previousLoggerConfig);
+        }
+      }
+      configuration.removeAppender(appender.getName());
+      appender.stop();
+      loggerContext.updateLoggers();
+    }
+    return events;
+  }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
index af300d029f..87b99cc44e 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
@@ -79,7 +79,7 @@ class TestAuthenticationOutOfMemoryHttp {
         new JettyServer() {
           /** {@inheritDoc} */
           @Override
-          protected Filter createAuthenticationFilter() {
+          protected Filter createAuthenticationFilter(boolean 
includeErrorStackTrace) {
             return new 
AuthenticationFilter(Collections.singletonList(authenticator)) {
               /** {@inheritDoc} */
               @Override
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
index 21b0bfc58d..909666c523 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
@@ -19,14 +19,25 @@
 package org.apache.gravitino.server.web;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 
+import com.google.common.io.CharStreams;
 import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import javax.servlet.Filter;
 import javax.servlet.Servlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.rest.RESTUtils;
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
@@ -37,6 +48,10 @@ import org.mockito.MockedStatic;
 
 public class TestJettyServer {
 
+  // The error page always names the servlet, so match a stack frame rather 
than the class name.
+  private static final String FAILING_SERVLET_STACK_FRAME =
+      FailingServlet.class.getName() + ".doGet(";
+
   private JettyServer jettyServer;
 
   @BeforeEach
@@ -90,6 +105,23 @@ public class TestJettyServer {
     jettyServer.stop();
   }
 
+  @Test
+  public void testErrorPageIncludesStackTraceByDefault() throws IOException {
+    String errorPage = requestFailingServlet(new Config(false) {});
+
+    assertTrue(errorPage.contains(FAILING_SERVLET_STACK_FRAME), errorPage);
+  }
+
+  @Test
+  public void testErrorPageOmitsStackTraceWhenDisabled() throws IOException {
+    Config config = new Config(false) {};
+    config.set(JettyServerConfig.INCLUDE_ERROR_STACK_TRACE, false);
+
+    String errorPage = requestFailingServlet(config);
+
+    assertFalse(errorPage.contains(FAILING_SERVLET_STACK_FRAME), errorPage);
+  }
+
   @Test
   public void testStopWithNullServer() {
     assertDoesNotThrow(() -> jettyServer.stop());
@@ -114,4 +146,33 @@ public class TestJettyServer {
       assertTrue(health.hasOutOfMemoryError());
     }
   }
+
+  /** Starts the server with a servlet that throws, and returns Jetty's error 
page for it. */
+  private String requestFailingServlet(Config config) throws IOException {
+    int port = RESTUtils.findAvailablePort(5000, 6000);
+    config.set(JettyServerConfig.WEBSERVER_HOST, "127.0.0.1");
+    config.set(JettyServerConfig.WEBSERVER_HTTP_PORT, port);
+    jettyServer.initialize(JettyServerConfig.fromConfig(config), "test", 
false);
+    jettyServer.addServlet(new FailingServlet(), "/fail");
+    jettyServer.start();
+
+    HttpURLConnection connection =
+        (HttpURLConnection) new URL("http://127.0.0.1:"; + port + 
"/fail").openConnection();
+    try {
+      assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, 
connection.getResponseCode());
+      try (Reader errorBody =
+          new InputStreamReader(connection.getErrorStream(), 
StandardCharsets.UTF_8)) {
+        return CharStreams.toString(errorBody);
+      }
+    } finally {
+      connection.disconnect();
+    }
+  }
+
+  private static class FailingServlet extends HttpServlet {
+    @Override
+    protected void doGet(HttpServletRequest request, HttpServletResponse 
response) {
+      throw new IllegalStateException("servlet failure");
+    }
+  }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServerConfig.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServerConfig.java
index 616d932191..5c33f9d486 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServerConfig.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServerConfig.java
@@ -31,6 +31,17 @@ import org.junit.jupiter.api.Test;
 
 public class TestJettyServerConfig {
 
+  @Test
+  public void testIncludeErrorStackTrace() {
+    Config config = new Config() {};
+    JettyServerConfig jettyServerConfig = JettyServerConfig.fromConfig(config, 
"");
+    Assertions.assertTrue(jettyServerConfig.isIncludeErrorStackTrace());
+
+    config.set(JettyServerConfig.INCLUDE_ERROR_STACK_TRACE, false);
+    jettyServerConfig = JettyServerConfig.fromConfig(config, "");
+    Assertions.assertFalse(jettyServerConfig.isIncludeErrorStackTrace());
+  }
+
   @Test
   public void testCipherAlgorithms() {
     Config noIntersectConfig = new Config() {};
diff --git 
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java 
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 0fb4b007b6..8b9a21844a 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -140,14 +140,14 @@ public class GravitinoServer extends ResourceConfig {
         new 
LineageConfig(serverConfig.getConfigsWithPrefix(LineageConfig.LINEAGE_CONFIG_PREFIX)));
 
     // initialize Jersey REST API resources.
-    initializeRestApi();
+    initializeRestApi(jettyServerConfig);
   }
 
   public ServerConfig serverConfig() {
     return serverConfig;
   }
 
-  private void initializeRestApi() {
+  private void initializeRestApi(JettyServerConfig jettyServerConfig) {
     HashSet<String> restApiPackagesSet = new HashSet<>();
     restApiPackagesSet.add("org.apache.gravitino.server.web.rest");
     
restApiPackagesSet.addAll(serverConfig.get(Configs.REST_API_EXTENSION_PACKAGES));
@@ -196,7 +196,8 @@ public class GravitinoServer extends ResourceConfig {
     register(ParamExceptionMapper.class);
     register(NotFoundExceptionMapper.class);
     register(WebApplicationExceptionMapper.class);
-    register(ObjectMapperProvider.class).register(JacksonFeature.class);
+    register(new 
ObjectMapperProvider(jettyServerConfig.isIncludeErrorStackTrace()))
+        .register(JacksonFeature.class);
     property(CommonProperties.JSON_JACKSON_DISABLED_MODULES, 
"DefaultScalaModule");
 
     if (!enableAuthorization) {
@@ -227,7 +228,8 @@ public class GravitinoServer extends ResourceConfig {
     server.addFilter(new RequestContextFilter(gravitinoEnv.eventBus()), 
API_ANY_PATH);
     server.addFilter(
         new HttpAuditFilter(gravitinoEnv.eventBus(), 
EventSource.GRAVITINO_SERVER), API_ANY_PATH);
-    server.addFilter(new VersioningFilter(), API_ANY_PATH);
+    server.addFilter(
+        new VersioningFilter(jettyServerConfig.isIncludeErrorStackTrace()), 
API_ANY_PATH);
 
     // GH-12760: servlets mounted outside API_ANY_PATH used to receive none of 
the filters below
     // (no request-context tracking, no audit-on-failure, no custom filters), 
with nothing in the
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java 
b/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
index 26fc3271c0..f6b8977838 100644
--- a/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
+++ b/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.server.web;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -94,6 +95,22 @@ public class VersioningFilter implements Filter {
   private static final String ACCEPT_VERSION_HEADER = "Accept";
   private static final String CONTENT_TYPE_HEADER = "Content-Type";
 
+  private final ObjectMapper objectMapper;
+
+  /** Creates a versioning filter with the backward-compatible error response 
behavior. */
+  public VersioningFilter() {
+    this.objectMapper = ObjectMapperProvider.objectMapper();
+  }
+
+  /**
+   * Creates a versioning filter with explicit error stack-trace response 
behavior.
+   *
+   * @param includeErrorStackTrace whether error responses should include 
diagnostic stack traces
+   */
+  public VersioningFilter(boolean includeErrorStackTrace) {
+    this.objectMapper = 
ObjectMapperProvider.objectMapper(includeErrorStackTrace);
+  }
+
   private static String getAcceptVersion(int version) {
     return String.format("application/vnd.gravitino.v%d+json", version);
   }
@@ -151,8 +168,7 @@ public class VersioningFilter implements Filter {
     return matcher.find() ? Integer.parseInt(matcher.group(1)) : null;
   }
 
-  private static boolean isUnsupportedVersion(int version, ServletResponse 
response)
-      throws IOException {
+  private boolean isUnsupportedVersion(int version, ServletResponse response) 
throws IOException {
     if (ApiVersion.isSupportedVersion(version)) {
       return false;
     }
@@ -168,7 +184,7 @@ public class VersioningFilter implements Filter {
     resp.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
     resp.setContentType("application/json");
     resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
-    ObjectMapperProvider.objectMapper().writeValue(resp.getWriter(), 
errorResponse);
+    objectMapper.writeValue(resp.getWriter(), errorResponse);
     return true;
   }
 
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index af16c2efd1..1cd0306af4 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -323,7 +323,8 @@ public class GravitinoInterceptionService implements 
InterceptionService {
             "User validation failed - User: {}, Metalake: {}, Reason: {}",
             currentUser,
             metalakeIdent.name(),
-            ex.getMessage());
+            ex.getMessage(),
+            ex);
         dispatchAuthzDenialEvent(currentUser, metalakeIdent, method.getName(), 
expression);
         return Optional.of(Utils.forbidden(ex.getMessage(), ex));
       } catch (Exception ex) {
diff --git 
a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java 
b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
index 2ad3f616ef..1288446005 100644
--- a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
+++ b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServer.java
@@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
@@ -235,6 +236,42 @@ public class TestGravitinoServer {
     assertEquals(404, response.statusCode());
   }
 
+  @Test
+  public void testJerseyErrorOmitsStackWhenDisabled() throws Exception {
+    Map<String, String> configs = new HashMap<>();
+    configs.put(
+        GravitinoServer.WEBSERVER_CONF_PREFIX + 
JettyServerConfig.WEBSERVER_HTTP_PORT.getKey(),
+        String.valueOf(RESTUtils.findAvailablePort(5000, 6000)));
+    configs.put(
+        GravitinoServer.WEBSERVER_CONF_PREFIX
+            + JettyServerConfig.INCLUDE_ERROR_STACK_TRACE.getKey(),
+        "false");
+    ServerConfig serverConfig = new ServerConfig();
+    serverConfig.loadFromMap(configs, key -> true);
+    serverConfig = spyServerConfig(serverConfig);
+    gravitinoServer = new GravitinoServer(serverConfig, 
GravitinoEnv.getInstance());
+    gravitinoServer.initialize();
+    gravitinoServer.start();
+
+    int port =
+        JettyServerConfig.fromConfig(serverConfig, 
GravitinoServer.WEBSERVER_CONF_PREFIX)
+            .getHttpPort();
+    HttpResponse<String> response =
+        HttpClient.newHttpClient()
+            .send(
+                HttpRequest.newBuilder(URI.create("http://127.0.0.1:"; + port + 
"/api/metalakes"))
+                    .header("Accept", "application/vnd.gravitino.v1+json")
+                    .header("Content-Type", "application/json")
+                    .POST(HttpRequest.BodyPublishers.ofString("{"))
+                    .build(),
+                HttpResponse.BodyHandlers.ofString());
+
+    assertEquals(400, response.statusCode(), response.body());
+    JsonNode responseJson = 
ObjectMapperProvider.objectMapper(false).readTree(response.body());
+    assertTrue(responseJson.hasNonNull("message"));
+    assertFalse(responseJson.has("stack"));
+  }
+
   @Test
   public void testSecretProvidersRequestIsAudited() throws Exception {
     // GH-12921 moved discovery under 
/api/metalakes/{metalake}/secrets/providers so it inherits
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/TestObjectMapperProvider.java
 
b/server/src/test/java/org/apache/gravitino/server/web/TestObjectMapperProvider.java
index 935f59f9f8..9ee9a12406 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/TestObjectMapperProvider.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/TestObjectMapperProvider.java
@@ -19,10 +19,15 @@
 package org.apache.gravitino.server.web;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.junit.jupiter.api.Test;
 
 public class TestObjectMapperProvider {
@@ -39,4 +44,48 @@ public class TestObjectMapperProvider {
         JsonInclude.Include.NON_NULL,
         
objectMapper.getSerializationConfig().getDefaultPropertyInclusion().getValueInclusion());
   }
+
+  @Test
+  public void testErrorResponseStackIsRedactedOnSerialization() throws 
JsonProcessingException {
+    ObjectMapper objectMapper = new 
ObjectMapperProvider(false).getContext(ErrorResponse.class);
+    ErrorResponse errorResponse =
+        ErrorResponse.internalError(
+            "public error message", new RuntimeException("private error 
details"));
+
+    assertNotNull(errorResponse.getStack());
+    assertTrue(
+        errorResponse.getStack().stream().anyMatch(line -> 
line.contains("private error details")));
+
+    JsonNode responseJson = 
objectMapper.readTree(objectMapper.writeValueAsString(errorResponse));
+    assertEquals("public error message", responseJson.get("message").asText());
+    assertFalse(responseJson.has("stack"));
+  }
+
+  @Test
+  public void testErrorResponseStackCanBeIncludedOnSerialization() throws 
JsonProcessingException {
+    ObjectMapper objectMapper = new 
ObjectMapperProvider().getContext(ErrorResponse.class);
+    ErrorResponse errorResponse =
+        ErrorResponse.internalError(
+            "public error message", new RuntimeException("private error 
details"));
+
+    JsonNode responseJson = 
objectMapper.readTree(objectMapper.writeValueAsString(errorResponse));
+    assertTrue(responseJson.has("stack"));
+    assertTrue(responseJson.get("stack").get(0).asText().contains("private 
error details"));
+  }
+
+  @Test
+  public void testErrorResponseStackIsAcceptedOnDeserialization() throws 
JsonProcessingException {
+    ObjectMapper serverObjectMapper = ObjectMapperProvider.objectMapper(false);
+    ObjectMapper clientObjectMapper = new ObjectMapper();
+    ErrorResponse errorResponse =
+        ErrorResponse.internalError(
+            "public error message", new RuntimeException("private error 
details"));
+    String legacyResponseJson = 
clientObjectMapper.writeValueAsString(errorResponse);
+
+    assertTrue(clientObjectMapper.readTree(legacyResponseJson).has("stack"));
+
+    ErrorResponse deserialized =
+        serverObjectMapper.readValue(legacyResponseJson, ErrorResponse.class);
+    assertEquals(errorResponse.getStack(), deserialized.getStack());
+  }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 572c492d68..35efcd1cfb 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -36,6 +36,7 @@ import java.lang.reflect.Method;
 import java.security.Principal;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.Response;
 import org.aopalliance.intercept.MethodInterceptor;
@@ -81,6 +82,14 @@ import org.apache.gravitino.server.web.rest.ViewOperations;
 import org.apache.gravitino.tag.TagDispatcher;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.gravitino.utils.RequestContext;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.AbstractConfiguration;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.layout.PatternLayout;
 import org.glassfish.hk2.api.Descriptor;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
@@ -719,55 +728,84 @@ public class TestGravitinoInterceptionService {
   /**
    * When {@code checkCurrentUser} throws {@link ForbiddenException} (user is 
not a metalake
    * member), the interceptor must dispatch an {@link 
AuthorizationDenialFailureEvent} and set
-   * {@code operationFailureFired}.
+   * {@code operationFailureFired}, while logging the original exception for 
server-side diagnosis.
    */
   @Test
-  public void testForbiddenExceptionDispatchesEventAndSetsFlag() throws 
Throwable {
-    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
-        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
-            mockStatic(GravitinoAuthorizerProvider.class);
-        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
-        MockedStatic<GravitinoEnv> envMocked = mockStatic(GravitinoEnv.class)) 
{
-
-      principalUtilsMocked
-          .when(PrincipalUtils::getCurrentPrincipal)
-          .thenReturn(new UserPrincipal("outsider"));
-      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("outsider");
-
-      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
-      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
-      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(new 
MockGravitinoAuthorizer());
-
-      authUtilsMocked
-          .when(
-              () ->
-                  AuthorizationUtils.checkCurrentUser(
-                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
-          .thenThrow(new ForbiddenException("User outsider is not a member"));
-
-      GravitinoEnv mockEnv = mock(GravitinoEnv.class);
-      EventBus mockEventBus = spy(new EventBus(Collections.emptyList()));
-      envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
-      when(mockEnv.eventBus()).thenReturn(mockEventBus);
-
-      GravitinoInterceptionService service = new 
GravitinoInterceptionService();
-      Method testMethod = TestOperations.class.getMethods()[0];
-      MethodInterceptor interceptor = 
service.getMethodInterceptors(testMethod).get(0);
-
-      MethodInvocation invocation = mock(MethodInvocation.class);
-      when(invocation.getMethod()).thenReturn(testMethod);
-      when(invocation.getArguments()).thenReturn(new Object[] 
{"testMetalake"});
-
-      Assertions.assertFalse(RequestContext.isOperationFailureFired());
-      Response response = (Response) interceptor.invoke(invocation);
-
-      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
-      ArgumentCaptor<AuthorizationDenialFailureEvent> captor =
-          ArgumentCaptor.forClass(AuthorizationDenialFailureEvent.class);
-      verify(mockEventBus).dispatchEvent(captor.capture());
-      AuthorizationDenialFailureEvent event = captor.getValue();
-      assertEquals("outsider", event.user());
-      Assertions.assertTrue(RequestContext.isOperationFailureFired());
+  public void testForbiddenExceptionDispatchesEventSetsFlagAndLogsThrowable() 
throws Throwable {
+    String loggerName =
+        GravitinoInterceptionService.class.getName() + 
"$MetadataAuthorizationMethodInterceptor";
+    LoggerContext loggerContext =
+        (LoggerContext)
+            
LogManager.getContext(GravitinoInterceptionService.class.getClassLoader(), 
false);
+    AbstractConfiguration configuration = (AbstractConfiguration) 
loggerContext.getConfiguration();
+    LoggerConfig previousLoggerConfig = 
configuration.getLoggers().get(loggerName);
+    CaptureAppender captureAppender = new 
CaptureAppender("authorizationCapture");
+    ForbiddenException forbiddenException = new ForbiddenException("User 
outsider is not a member");
+    try {
+      captureAppender.start();
+      configuration.addAppender(captureAppender);
+      LoggerConfig loggerConfig = new LoggerConfig(loggerName, Level.WARN, 
false);
+      loggerConfig.addAppender(captureAppender, Level.WARN, null);
+      configuration.addLogger(loggerName, loggerConfig);
+      loggerContext.updateLoggers();
+
+      try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+          MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+              mockStatic(GravitinoAuthorizerProvider.class);
+          MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class);
+          MockedStatic<GravitinoEnv> envMocked = 
mockStatic(GravitinoEnv.class)) {
+
+        principalUtilsMocked
+            .when(PrincipalUtils::getCurrentPrincipal)
+            .thenReturn(new UserPrincipal("outsider"));
+        
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("outsider");
+
+        GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+        
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+        when(mockedProvider.getGravitinoAuthorizer()).thenReturn(new 
MockGravitinoAuthorizer());
+
+        authUtilsMocked
+            .when(
+                () ->
+                    AuthorizationUtils.checkCurrentUser(
+                        ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+            .thenThrow(forbiddenException);
+
+        GravitinoEnv mockEnv = mock(GravitinoEnv.class);
+        EventBus mockEventBus = spy(new EventBus(Collections.emptyList()));
+        envMocked.when(GravitinoEnv::getInstance).thenReturn(mockEnv);
+        when(mockEnv.eventBus()).thenReturn(mockEventBus);
+
+        GravitinoInterceptionService service = new 
GravitinoInterceptionService();
+        Method testMethod = TestOperations.class.getMethods()[0];
+        MethodInterceptor interceptor = 
service.getMethodInterceptors(testMethod).get(0);
+
+        MethodInvocation invocation = mock(MethodInvocation.class);
+        when(invocation.getMethod()).thenReturn(testMethod);
+        when(invocation.getArguments()).thenReturn(new Object[] 
{"testMetalake"});
+
+        Assertions.assertFalse(RequestContext.isOperationFailureFired());
+        Response response = (Response) interceptor.invoke(invocation);
+
+        assertEquals(Response.Status.FORBIDDEN.getStatusCode(), 
response.getStatus());
+        ArgumentCaptor<AuthorizationDenialFailureEvent> captor =
+            ArgumentCaptor.forClass(AuthorizationDenialFailureEvent.class);
+        verify(mockEventBus).dispatchEvent(captor.capture());
+        AuthorizationDenialFailureEvent event = captor.getValue();
+        assertEquals("outsider", event.user());
+        Assertions.assertTrue(RequestContext.isOperationFailureFired());
+        Assertions.assertTrue(
+            captureAppender.getEvents().stream()
+                .anyMatch(logEvent -> logEvent.getThrown() == 
forbiddenException));
+      }
+    } finally {
+      configuration.removeLogger(loggerName);
+      if (previousLoggerConfig != null) {
+        configuration.addLogger(loggerName, previousLoggerConfig);
+      }
+      configuration.removeAppender(captureAppender.getName());
+      captureAppender.stop();
+      loggerContext.updateLoggers();
     }
   }
 
@@ -1228,4 +1266,21 @@ public class TestGravitinoInterceptionService {
     @Override
     public void close() throws IOException {}
   }
+
+  private static class CaptureAppender extends AbstractAppender {
+    private final List<LogEvent> events = new CopyOnWriteArrayList<>();
+
+    CaptureAppender(String name) {
+      super(name, null, PatternLayout.createDefaultLayout(), true, null);
+    }
+
+    @Override
+    public void append(LogEvent event) {
+      events.add(event.toImmutable());
+    }
+
+    List<LogEvent> getEvents() {
+      return events;
+    }
+  }
 }

Reply via email to