You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by yu...@apache.org on 2025/10/31 05:40:05 UTC

(polaris) branch main updated: Implement OpaPolarisAuthorizer (#2680)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 577b32a7f Implement OpaPolarisAuthorizer (#2680)
577b32a7f is described below

commit 577b32a7fab36a2a902e50c3930d19ac995618e2
Author: Sung Yun <10...@users.noreply.github.com>
AuthorDate: Fri Oct 31 01:40:01 2025 -0400

    Implement OpaPolarisAuthorizer (#2680)
---
 bom/build.gradle.kts                               |   2 +
 extensions/auth/opa/impl/build.gradle.kts          |  60 ++
 .../extension/auth/opa/OpaAuthorizationConfig.java | 187 ++++++
 .../extension/auth/opa/OpaHttpClientFactory.java   | 130 ++++
 .../extension/auth/opa/OpaPolarisAuthorizer.java   | 319 ++++++++++
 .../auth/opa/OpaPolarisAuthorizerFactory.java      | 186 ++++++
 .../auth/opa/OpaProductionReadinessChecks.java     |  56 ++
 .../auth/opa/token/BearerTokenProvider.java        |  52 ++
 .../auth/opa/token/FileBearerTokenProvider.java    | 252 ++++++++
 .../auth/opa/token/StaticBearerTokenProvider.java  |  39 ++
 .../auth/opa/OpaHttpClientFactoryTest.java         |  49 ++
 .../auth/opa/OpaPolarisAuthorizerFactoryTest.java  | 163 +++++
 .../auth/opa/OpaPolarisAuthorizerTest.java         | 663 +++++++++++++++++++++
 .../opa/token/FileBearerTokenProviderTest.java     | 471 +++++++++++++++
 .../opa/token/StaticBearerTokenProviderTest.java   |  50 ++
 extensions/auth/opa/tests/build.gradle.kts         |  73 +++
 .../auth/opa/test/OpaFileTokenIntegrationTest.java | 130 ++++
 .../auth/opa/test/OpaIntegrationTest.java          | 111 ++++
 .../auth/opa/test/OpaIntegrationTestBase.java      | 131 ++++
 .../extension/auth/opa/test/OpaTestResource.java   | 131 ++++
 .../extension/auth/opa/test/Dockerfile-opa-version |  19 +
 gradle/libs.versions.toml                          |   1 +
 gradle/projects.main.properties                    |   2 +
 .../core/auth/DefaultPolarisAuthorizerFactory.java |  30 +
 .../core/auth/PolarisAuthorizerFactory.java        |  38 ++
 .../polaris/core/auth/PolarisAuthorizerImpl.java   |   2 -
 .../src/main/resources/application.properties      |  34 ++
 runtime/server/build.gradle.kts                    |   1 +
 runtime/service/build.gradle.kts                   |   2 +
 .../polaris/service/it/ServiceProducersIT.java     |  54 ++
 .../service/config/AuthorizationConfiguration.java |  29 +
 .../polaris/service/config/ServiceProducers.java   |  25 +-
 32 files changed, 3487 insertions(+), 5 deletions(-)

diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts
index c6f0713eb..3a0688880 100644
--- a/bom/build.gradle.kts
+++ b/bom/build.gradle.kts
@@ -56,6 +56,8 @@ dependencies {
     api(project(":polaris-eclipselink"))
     api(project(":polaris-relational-jdbc"))
 
+    api(project(":polaris-extensions-auth-opa"))
+
     api(project(":polaris-admin"))
     api(project(":polaris-runtime-common"))
     api(project(":polaris-runtime-test-common"))
diff --git a/extensions/auth/opa/impl/build.gradle.kts b/extensions/auth/opa/impl/build.gradle.kts
new file mode 100644
index 000000000..9dd95259d
--- /dev/null
+++ b/extensions/auth/opa/impl/build.gradle.kts
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+  id("polaris-server")
+  id("org.kordamp.gradle.jandex")
+}
+
+dependencies {
+  implementation(project(":polaris-core"))
+  implementation(libs.apache.httpclient5)
+  implementation(platform(libs.jackson.bom))
+  implementation("com.fasterxml.jackson.core:jackson-core")
+  implementation("com.fasterxml.jackson.core:jackson-databind")
+  implementation(libs.guava)
+  implementation(libs.slf4j.api)
+  implementation(libs.auth0.jwt)
+  implementation(project(":polaris-async-api"))
+
+  // Iceberg dependency for ForbiddenException
+  implementation(platform(libs.iceberg.bom))
+  implementation("org.apache.iceberg:iceberg-api")
+
+  compileOnly(project(":polaris-immutables"))
+  annotationProcessor(project(":polaris-immutables", configuration = "processor"))
+
+  compileOnly(libs.jakarta.annotation.api)
+  compileOnly(libs.jakarta.enterprise.cdi.api)
+  compileOnly(libs.jakarta.inject.api)
+  compileOnly(libs.smallrye.config.core)
+
+  testCompileOnly(project(":polaris-immutables"))
+  testAnnotationProcessor(project(":polaris-immutables", configuration = "processor"))
+
+  testImplementation(testFixtures(project(":polaris-core")))
+  testImplementation(platform(libs.junit.bom))
+  testImplementation("org.junit.jupiter:junit-jupiter")
+  testImplementation(libs.assertj.core)
+  testImplementation(libs.mockito.core)
+  testImplementation(libs.threeten.extra)
+  testImplementation(testFixtures(project(":polaris-async-api")))
+  testImplementation(project(":polaris-async-java"))
+  testImplementation(project(":polaris-idgen-mocks"))
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java
new file mode 100644
index 000000000..0eed8f09a
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaAuthorizationConfig.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+import java.net.URI;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Optional;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * Configuration for OPA (Open Policy Agent) authorization.
+ *
+ * <p><strong>Beta Feature:</strong> OPA authorization is currently in Beta and is not a stable
+ * release. It may undergo breaking changes in future versions. Use with caution in production
+ * environments.
+ */
+@PolarisImmutable
+@ConfigMapping(prefix = "polaris.authorization.opa")
+public interface OpaAuthorizationConfig {
+
+  /** Authentication types supported by OPA authorization */
+  enum AuthenticationType {
+    NONE("none"),
+    BEARER("bearer");
+
+    private final String value;
+
+    AuthenticationType(String value) {
+      this.value = value;
+    }
+
+    public String getValue() {
+      return value;
+    }
+  }
+
+  Optional<URI> policyUri();
+
+  AuthenticationConfig auth();
+
+  HttpConfig http();
+
+  /** Validates the complete OPA configuration */
+  default void validate() {
+    checkArgument(
+        policyUri().isPresent(), "polaris.authorization.opa.policy-uri must be configured");
+
+    URI uri = policyUri().get();
+    String scheme = uri.getScheme();
+    checkArgument(
+        "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme),
+        "polaris.authorization.opa.policy-uri must use http or https scheme, but got: " + scheme);
+
+    auth().validate();
+  }
+
+  /** HTTP client configuration for OPA communication. */
+  @PolarisImmutable
+  interface HttpConfig {
+    @WithDefault("PT2S")
+    Duration timeout();
+
+    @WithDefault("true")
+    boolean verifySsl();
+
+    Optional<Path> trustStorePath();
+
+    Optional<String> trustStorePassword();
+  }
+
+  /** Authentication configuration for OPA communication. */
+  @PolarisImmutable
+  interface AuthenticationConfig {
+    /** Type of authentication */
+    @WithDefault("none")
+    AuthenticationType type();
+
+    /** Bearer token authentication configuration */
+    Optional<BearerTokenConfig> bearer();
+
+    default void validate() {
+      switch (type()) {
+        case BEARER:
+          checkArgument(
+              bearer().isPresent(), "Bearer configuration is required when type is 'bearer'");
+          bearer().get().validate();
+          break;
+        case NONE:
+          // No authentication - nothing to validate
+          break;
+        default:
+          throw new IllegalArgumentException(
+              "Invalid authentication type: " + type() + ". Supported types: 'bearer', 'none'");
+      }
+    }
+  }
+
+  @PolarisImmutable
+  interface BearerTokenConfig {
+    /** Static bearer token configuration */
+    Optional<StaticTokenConfig> staticToken();
+
+    /** File-based bearer token configuration */
+    Optional<FileBasedConfig> fileBased();
+
+    default void validate() {
+      // Ensure exactly one bearer token configuration is present (mutually exclusive)
+      checkArgument(
+          staticToken().isPresent() ^ fileBased().isPresent(),
+          "Exactly one of 'static-token' or 'file-based' bearer token configuration must be specified");
+
+      // Validate the present configuration
+      if (staticToken().isPresent()) {
+        staticToken().get().validate();
+      } else {
+        fileBased().get().validate();
+      }
+    }
+
+    /** Configuration for static bearer tokens */
+    @PolarisImmutable
+    interface StaticTokenConfig {
+      /** Static bearer token value */
+      String value();
+
+      default void validate() {
+        checkArgument(
+            !Strings.isNullOrEmpty(value()), "Static bearer token value cannot be null or empty");
+      }
+    }
+
+    /** Configuration for file-based bearer tokens */
+    @PolarisImmutable
+    interface FileBasedConfig {
+      /** Path to file containing bearer token */
+      Path path();
+
+      /** How often to refresh file-based bearer tokens (defaults to 5 minutes if not specified) */
+      Optional<Duration> refreshInterval();
+
+      /**
+       * Whether to automatically detect JWT tokens and use their 'exp' field for refresh timing. If
+       * true and the token is a valid JWT with an 'exp' claim, the token will be refreshed based on
+       * the expiration time minus the buffer, rather than the fixed refresh interval. Defaults to
+       * true if not specified.
+       */
+      Optional<Boolean> jwtExpirationRefresh();
+
+      /**
+       * Buffer time before JWT expiration to refresh the token. Only used when jwtExpirationRefresh
+       * is true and the token is a valid JWT. Defaults to 1 minute if not specified.
+       */
+      Optional<Duration> jwtExpirationBuffer();
+
+      default void validate() {
+        checkArgument(
+            refreshInterval().isEmpty() || refreshInterval().get().isPositive(),
+            "refreshInterval must be positive");
+        checkArgument(
+            jwtExpirationBuffer().isEmpty() || jwtExpirationBuffer().get().isPositive(),
+            "jwtExpirationBuffer must be positive");
+      }
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java
new file mode 100644
index 000000000..db9febb62
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactory.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import java.io.FileInputStream;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLContext;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.core5.ssl.SSLContexts;
+import org.apache.hc.core5.util.Timeout;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Factory for creating HTTP clients configured for OPA communication with SSL support.
+ *
+ * <p>This factory handles the creation of Apache HttpClient instances with proper SSL
+ * configuration, timeout settings, and connection pooling for communicating with Open Policy Agent
+ * (OPA) servers.
+ */
+class OpaHttpClientFactory {
+  private static final Logger logger = LoggerFactory.getLogger(OpaHttpClientFactory.class);
+
+  /**
+   * Creates a configured HTTP client for OPA communication.
+   *
+   * @param config HTTP configuration for timeouts and SSL settings
+   * @return configured CloseableHttpClient
+   */
+  public static CloseableHttpClient createHttpClient(OpaAuthorizationConfig.HttpConfig config) {
+    RequestConfig requestConfig =
+        RequestConfig.custom()
+            .setResponseTimeout(Timeout.ofMilliseconds(config.timeout().toMillis()))
+            .build();
+
+    try {
+      // Create TLS strategy based on configuration
+      DefaultClientTlsStrategy tlsStrategy = createTlsStrategy(config);
+
+      // Create connection manager with the TLS strategy
+      var connectionManager =
+          PoolingHttpClientConnectionManagerBuilder.create()
+              .setTlsSocketStrategy(tlsStrategy)
+              .build();
+
+      return HttpClients.custom()
+          .setConnectionManager(connectionManager)
+          .setDefaultRequestConfig(requestConfig)
+          .build();
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to create HTTP client for OPA communication", e);
+    }
+  }
+
+  /**
+   * Creates a TLS strategy based on the configuration.
+   *
+   * @param config HTTP configuration containing SSL settings
+   * @return DefaultClientTlsStrategy for HTTPS connections
+   */
+  private static DefaultClientTlsStrategy createTlsStrategy(
+      OpaAuthorizationConfig.HttpConfig config) throws Exception {
+    SSLContext sslContext = createSslContext(config);
+
+    if (!config.verifySsl()) {
+      // Disable hostname verification when SSL verification is disabled
+      return new DefaultClientTlsStrategy(sslContext, NoopHostnameVerifier.INSTANCE);
+    } else {
+      // Use default hostname verification when SSL verification is enabled
+      return new DefaultClientTlsStrategy(sslContext);
+    }
+  }
+
+  /**
+   * Creates an SSL context based on the configuration.
+   *
+   * @param config HTTP configuration containing SSL settings
+   * @return SSLContext for HTTPS connections
+   */
+  private static SSLContext createSslContext(OpaAuthorizationConfig.HttpConfig config)
+      throws Exception {
+    if (!config.verifySsl()) {
+      // Disable SSL verification (for development/testing)
+      logger.warn(
+          "SSL verification is disabled for OPA server. This should only be used in development/testing environments.");
+      return SSLContexts.custom()
+          .loadTrustMaterial(
+              null, (X509Certificate[] chain, String authType) -> true) // trust all certificates
+          .build();
+    } else if (config.trustStorePath().isPresent()) {
+      // Load custom trust store for SSL verification
+      Path trustStorePath = config.trustStorePath().get();
+      logger.info("Loading custom trust store for OPA SSL verification: {}", trustStorePath);
+      KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+      try (FileInputStream trustStoreStream = new FileInputStream(trustStorePath.toFile())) {
+        String trustStorePassword = config.trustStorePassword().orElse(null);
+        trustStore.load(
+            trustStoreStream, trustStorePassword != null ? trustStorePassword.toCharArray() : null);
+      }
+      return SSLContexts.custom().loadTrustMaterial(trustStore, null).build();
+    } else {
+      // Use default system trust store for SSL verification
+      logger.debug("Using default system trust store for OPA SSL verification");
+      return SSLContexts.createDefault();
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
new file mode 100644
index 000000000..ab985abfe
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
@@ -0,0 +1,319 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.io.IOException;
+import java.net.URI;
+import java.util.List;
+import java.util.Set;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
+
+/**
+ * OPA-based implementation of {@link PolarisAuthorizer}.
+ *
+ * <p>This authorizer delegates authorization decisions to an Open Policy Agent (OPA) server using a
+ * configurable REST API endpoint and policy path. The input to OPA is constructed from the
+ * principal, entities, operation, and resource context.
+ *
+ * <p><strong>Beta Feature:</strong> This implementation is currently in Beta and is not a stable
+ * release. It may undergo breaking changes in future versions. Use with caution in production
+ * environments.
+ */
+class OpaPolarisAuthorizer implements PolarisAuthorizer {
+  private final URI policyUri;
+  private final BearerTokenProvider tokenProvider;
+  private final CloseableHttpClient httpClient;
+  private final ObjectMapper objectMapper;
+
+  /**
+   * Public constructor that accepts a complete policy URI.
+   *
+   * @param policyUri The required URI for the OPA endpoint. For example,
+   *     https://opa.example.com/v1/polaris/allow
+   * @param httpClient Apache HttpClient (required, injected by CDI). SSL configuration should be
+   *     handled by the CDI producer.
+   * @param objectMapper Jackson ObjectMapper for JSON serialization (required). Shared across
+   *     authorizer instances to avoid initialization overhead.
+   * @param tokenProvider Token provider for authentication (optional)
+   */
+  public OpaPolarisAuthorizer(
+      @Nonnull URI policyUri,
+      @Nonnull CloseableHttpClient httpClient,
+      @Nonnull ObjectMapper objectMapper,
+      @Nullable BearerTokenProvider tokenProvider) {
+
+    this.policyUri = policyUri;
+    this.tokenProvider = tokenProvider;
+    this.httpClient = httpClient;
+    this.objectMapper = objectMapper;
+  }
+
+  /**
+   * Authorizes a single target and secondary entity for the given principal and operation.
+   *
+   * <p>Delegates to the multi-target version for consistency.
+   *
+   * @param polarisPrincipal the principal requesting authorization
+   * @param activatedEntities the set of activated entities (roles, etc.)
+   * @param authzOp the operation to authorize
+   * @param target the main target entity
+   * @param secondary the secondary entity (if any)
+   * @throws ForbiddenException if authorization is denied by OPA
+   */
+  @Override
+  public void authorizeOrThrow(
+      @Nonnull PolarisPrincipal polarisPrincipal,
+      @Nonnull Set<PolarisBaseEntity> activatedEntities,
+      @Nonnull PolarisAuthorizableOperation authzOp,
+      @Nullable PolarisResolvedPathWrapper target,
+      @Nullable PolarisResolvedPathWrapper secondary) {
+    authorizeOrThrow(
+        polarisPrincipal,
+        activatedEntities,
+        authzOp,
+        target == null ? null : List.of(target),
+        secondary == null ? null : List.of(secondary));
+  }
+
+  /**
+   * Authorizes one or more target and secondary entities for the given principal and operation.
+   *
+   * <p>Sends the authorization context to OPA and throws if not allowed.
+   *
+   * @param polarisPrincipal the principal requesting authorization
+   * @param activatedEntities the set of activated entities (roles, etc.)
+   * @param authzOp the operation to authorize
+   * @param targets the list of main target entities
+   * @param secondaries the list of secondary entities (if any)
+   * @throws ForbiddenException if authorization is denied by OPA
+   */
+  @Override
+  public void authorizeOrThrow(
+      @Nonnull PolarisPrincipal polarisPrincipal,
+      @Nonnull Set<PolarisBaseEntity> activatedEntities,
+      @Nonnull PolarisAuthorizableOperation authzOp,
+      @Nullable List<PolarisResolvedPathWrapper> targets,
+      @Nullable List<PolarisResolvedPathWrapper> secondaries) {
+    boolean allowed = queryOpa(polarisPrincipal, activatedEntities, authzOp, targets, secondaries);
+    if (!allowed) {
+      throw new ForbiddenException("OPA denied authorization");
+    }
+  }
+
+  /**
+   * Sends an authorization query to the OPA server and parses the response.
+   *
+   * <p>Builds the OPA input JSON, sends it via HTTP POST, and checks the 'allow' field in the
+   * response. The request format follows the OPA REST API specification for data queries.
+   *
+   * @param principal the principal requesting authorization
+   * @param entities the set of activated entities
+   * @param op the operation to authorize
+   * @param targets the list of main target entities
+   * @param secondaries the list of secondary entities (if any)
+   * @return true if OPA allows the operation, false otherwise
+   * @throws RuntimeException if the OPA query fails
+   * @see <a href="https://www.openpolicyagent.org/docs/rest-api">OPA REST API Documentation</a>
+   */
+  private boolean queryOpa(
+      PolarisPrincipal principal,
+      Set<PolarisBaseEntity> entities,
+      PolarisAuthorizableOperation op,
+      List<PolarisResolvedPathWrapper> targets,
+      List<PolarisResolvedPathWrapper> secondaries) {
+    try {
+      String inputJson = buildOpaInputJson(principal, entities, op, targets, secondaries);
+
+      // Create HTTP POST request using Apache HttpComponents
+      HttpPost httpPost = new HttpPost(policyUri);
+      httpPost.setHeader("Content-Type", "application/json");
+
+      // Add bearer token authentication if provided
+      if (tokenProvider != null) {
+        String token = tokenProvider.getToken();
+        if (token != null && !token.isEmpty()) {
+          httpPost.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
+        }
+      }
+
+      httpPost.setEntity(new StringEntity(inputJson, ContentType.APPLICATION_JSON));
+
+      // Execute request
+      try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
+        int statusCode = response.getCode();
+        if (statusCode != 200) {
+          return false;
+        }
+
+        // Read and parse response
+        String responseBody;
+        try {
+          responseBody = EntityUtils.toString(response.getEntity());
+        } catch (ParseException e) {
+          throw new RuntimeException("Failed to parse OPA response", e);
+        }
+        ObjectNode respNode = (ObjectNode) objectMapper.readTree(responseBody);
+        return respNode.path("result").path("allow").asBoolean(false);
+      }
+    } catch (IOException e) {
+      throw new RuntimeException("OPA query failed", e);
+    }
+  }
+
+  /**
+   * Builds the OPA input JSON for the authorization query.
+   *
+   * <p>Assembles the actor, action, resource, and context sections into the expected OPA input
+   * format.
+   *
+   * <p><strong>Note:</strong> OpaPolarisAuthorizer bypasses Polaris's built-in role-based
+   * authorization system. This includes both principal roles and catalog roles that would normally
+   * be processed by Polaris. Instead, authorization decisions are delegated entirely to the
+   * configured OPA policies, which receive the raw principal information and must implement their
+   * own role/permission logic.
+   *
+   * @param principal the principal requesting authorization
+   * @param entities the set of activated entities
+   * @param op the operation to authorize
+   * @param targets the list of main target entities
+   * @param secondaries the list of secondary entities (if any)
+   * @return the OPA input JSON string
+   * @throws IOException if JSON serialization fails
+   */
+  private String buildOpaInputJson(
+      PolarisPrincipal principal,
+      Set<PolarisBaseEntity> entities,
+      PolarisAuthorizableOperation op,
+      List<PolarisResolvedPathWrapper> targets,
+      List<PolarisResolvedPathWrapper> secondaries)
+      throws IOException {
+    ObjectNode input = objectMapper.createObjectNode();
+    input.set("actor", buildActorNode(principal));
+    input.put("action", op.name());
+    input.set("resource", buildResourceNode(targets, secondaries));
+    input.set("context", buildContextNode());
+    ObjectNode root = objectMapper.createObjectNode();
+    root.set("input", input);
+    return objectMapper.writeValueAsString(root);
+  }
+
+  /**
+   * Builds the actor section of the OPA input JSON.
+   *
+   * <p>Includes principal name, and roles as a generic field.
+   *
+   * @param principal the principal requesting authorization
+   * @return the actor node for OPA input
+   */
+  private ObjectNode buildActorNode(PolarisPrincipal principal) {
+    ObjectNode actor = objectMapper.createObjectNode();
+    actor.put("principal", principal.getName());
+    ArrayNode roles = objectMapper.createArrayNode();
+    for (String role : principal.getRoles()) roles.add(role);
+    actor.set("roles", roles);
+    return actor;
+  }
+
+  /**
+   * Builds the resource section of the OPA input JSON.
+   *
+   * <p>Includes the main target entity under 'primary' and secondary entities under 'secondaries'.
+   *
+   * @param targets the list of main target entities
+   * @param secondaries the list of secondary entities
+   * @return the resource node for OPA input
+   */
+  private ObjectNode buildResourceNode(
+      List<PolarisResolvedPathWrapper> targets, List<PolarisResolvedPathWrapper> secondaries) {
+    ObjectNode resource = objectMapper.createObjectNode();
+    // Main targets as 'targets' array
+    ArrayNode targetsArray = objectMapper.createArrayNode();
+    if (targets != null && !targets.isEmpty()) {
+      for (PolarisResolvedPathWrapper targetWrapper : targets) {
+        targetsArray.add(buildSingleResourceNode(targetWrapper));
+      }
+    }
+    resource.set("targets", targetsArray);
+    // Secondaries as array
+    ArrayNode secondariesArray = objectMapper.createArrayNode();
+    if (secondaries != null && !secondaries.isEmpty()) {
+      for (PolarisResolvedPathWrapper secondaryWrapper : secondaries) {
+        secondariesArray.add(buildSingleResourceNode(secondaryWrapper));
+      }
+    }
+    resource.set("secondaries", secondariesArray);
+    return resource;
+  }
+
+  /** Helper to build a resource node for a single PolarisResolvedPathWrapper. */
+  private ObjectNode buildSingleResourceNode(PolarisResolvedPathWrapper wrapper) {
+    ObjectNode node = objectMapper.createObjectNode();
+    if (wrapper == null) return node;
+    var resolvedEntity = wrapper.getResolvedLeafEntity();
+    if (resolvedEntity != null) {
+      var entity = resolvedEntity.getEntity();
+      node.put("type", entity.getType().name());
+      node.put("name", entity.getName());
+      var parentPath = wrapper.getResolvedParentPath();
+      if (parentPath != null && !parentPath.isEmpty()) {
+        ArrayNode parentsArray = objectMapper.createArrayNode();
+        for (var parent : parentPath) {
+          ObjectNode parentNode = objectMapper.createObjectNode();
+          parentNode.put("type", parent.getEntity().getType().name());
+          parentNode.put("name", parent.getEntity().getName());
+          parentsArray.add(parentNode);
+        }
+        node.set("parents", parentsArray);
+      }
+    }
+    return node;
+  }
+
+  /**
+   * Builds the context section of the OPA input JSON.
+   *
+   * <p>Includes a request ID for correlating OPA server requests with logs.
+   *
+   * @return the context node for OPA input
+   */
+  private ObjectNode buildContextNode() {
+    ObjectNode context = objectMapper.createObjectNode();
+    context.put("request_id", java.util.UUID.randomUUID().toString());
+    return context;
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
new file mode 100644
index 000000000..c3f72b4fa
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.smallrye.common.annotation.Identifier;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.io.IOException;
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
+import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
+import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider;
+import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider;
+import org.apache.polaris.nosql.async.AsyncExec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Factory for creating OPA-based Polaris authorizer implementations. */
+@ApplicationScoped
+@Identifier("opa")
+class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
+
+  private static final Logger logger = LoggerFactory.getLogger(OpaPolarisAuthorizerFactory.class);
+
+  private final OpaAuthorizationConfig opaConfig;
+  private final Clock clock;
+  private final ObjectMapper objectMapper;
+  private final AsyncExec asyncExec;
+  private CloseableHttpClient httpClient;
+  private BearerTokenProvider bearerTokenProvider;
+
+  @Inject
+  public OpaPolarisAuthorizerFactory(
+      OpaAuthorizationConfig opaConfig, Clock clock, AsyncExec asyncExec) {
+    this.opaConfig = opaConfig;
+    this.clock = clock;
+    this.asyncExec = asyncExec;
+    this.objectMapper = new ObjectMapper();
+  }
+
+  /**
+   * Gets the OPA authorization configuration. Used by OpaProductionReadinessCheck
+   *
+   * @return the OPA configuration
+   */
+  OpaAuthorizationConfig getConfig() {
+    return opaConfig;
+  }
+
+  @PostConstruct
+  public void initialize() {
+    // Validate configuration once during startup
+    opaConfig.validate();
+
+    // Create HTTP client once during startup
+    httpClient = createHttpClient();
+
+    // Setup authentication once during startup
+    setupAuthentication(opaConfig.auth());
+  }
+
+  @Override
+  public PolarisAuthorizer create(RealmConfig realmConfig) {
+    // All components are now pre-initialized, just create the authorizer
+    URI policyUri =
+        opaConfig
+            .policyUri()
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        "OPA policy URI must be configured via polaris.authorization.opa.policy-uri"));
+
+    return new OpaPolarisAuthorizer(policyUri, httpClient, objectMapper, bearerTokenProvider);
+  }
+
+  @PreDestroy
+  public void cleanup() {
+    // Clean up bearer token provider resources
+    if (bearerTokenProvider != null) {
+      try {
+        bearerTokenProvider.close();
+        logger.debug("Bearer token provider closed successfully");
+      } catch (Exception e) {
+        // Log but don't throw - we're shutting down anyway
+        logger.warn("Error closing bearer token provider: {}", e.getMessage(), e);
+      }
+    }
+
+    // Clean up HTTP client resources
+    if (httpClient != null) {
+      try {
+        httpClient.close();
+        logger.debug("HTTP client closed successfully");
+      } catch (IOException e) {
+        // Log but don't throw - we're shutting down anyway
+        logger.warn("Error closing HTTP client: {}", e.getMessage(), e);
+      }
+    }
+  }
+
+  private CloseableHttpClient createHttpClient() {
+    try {
+      return OpaHttpClientFactory.createHttpClient(opaConfig.http());
+    } catch (Exception e) {
+      // Fallback to simple client
+      return HttpClients.custom().build();
+    }
+  }
+
+  /**
+   * Sets up authentication based on the configuration.
+   *
+   * <p>This method handles different authentication types and configures the appropriate
+   * authentication mechanism. Future authentication types (e.g., TLS mutual authentication) can be
+   * added as additional cases.
+   */
+  private void setupAuthentication(OpaAuthorizationConfig.AuthenticationConfig authConfig) {
+    switch (authConfig.type()) {
+      case BEARER:
+        if (authConfig.bearer().isEmpty()) {
+          throw new IllegalStateException("Bearer configuration is required when type is 'bearer'");
+        }
+        this.bearerTokenProvider = createBearerTokenProvider(authConfig.bearer().get());
+        break;
+      case NONE:
+        this.bearerTokenProvider = null; // No authentication
+        break;
+      default:
+        throw new IllegalStateException("Unsupported authentication type: " + authConfig.type());
+    }
+  }
+
+  private BearerTokenProvider createBearerTokenProvider(
+      OpaAuthorizationConfig.BearerTokenConfig bearerToken) {
+    // Check which configuration is present
+    if (bearerToken.staticToken().isPresent()) {
+      OpaAuthorizationConfig.BearerTokenConfig.StaticTokenConfig staticConfig =
+          bearerToken.staticToken().get();
+      return new StaticBearerTokenProvider(staticConfig.value());
+    } else if (bearerToken.fileBased().isPresent()) {
+      OpaAuthorizationConfig.BearerTokenConfig.FileBasedConfig fileConfig =
+          bearerToken.fileBased().get();
+
+      Duration refreshInterval = fileConfig.refreshInterval().orElse(Duration.ofMinutes(5));
+      boolean jwtExpirationRefresh = fileConfig.jwtExpirationRefresh().orElse(true);
+      Duration jwtExpirationBuffer = fileConfig.jwtExpirationBuffer().orElse(Duration.ofMinutes(1));
+
+      return new FileBearerTokenProvider(
+          fileConfig.path(),
+          refreshInterval,
+          jwtExpirationRefresh,
+          jwtExpirationBuffer,
+          Duration.ofSeconds(5), // TODO: make configurable
+          asyncExec,
+          clock::instant);
+    } else {
+      throw new IllegalStateException(
+          "No bearer token configuration found. Must specify either 'static-token' or 'file-based'");
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java
new file mode 100644
index 000000000..af6696743
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaProductionReadinessChecks.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
+import org.apache.polaris.core.config.ProductionReadinessCheck;
+import org.apache.polaris.core.config.ProductionReadinessCheck.Error;
+
+@ApplicationScoped
+public class OpaProductionReadinessChecks {
+
+  @Produces
+  public ProductionReadinessCheck checkOpaAuthorization(
+      PolarisAuthorizerFactory authorizerFactory) {
+    if (authorizerFactory instanceof OpaPolarisAuthorizerFactory opaFactory) {
+      OpaAuthorizationConfig config = opaFactory.getConfig();
+
+      List<Error> errors = new ArrayList<>();
+
+      errors.add(
+          Error.of(
+              "OPA authorization is currently a Beta feature and is not a stable release. Breaking changes may be introduced in future versions. Use with caution in production environments.",
+              "polaris.authorization.type"));
+
+      if (!config.http().verifySsl()) {
+        errors.add(
+            Error.ofSevere(
+                "SSL certificate verification is disabled for OPA communication. This exposes the service to man-in-the-middle attacks and other severe security risks.",
+                "polaris.authorization.opa.http.verify-ssl"));
+      }
+
+      return ProductionReadinessCheck.of(errors);
+    }
+    return ProductionReadinessCheck.OK;
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java
new file mode 100644
index 000000000..3d5c1b4f7
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/BearerTokenProvider.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import jakarta.annotation.Nullable;
+
+/**
+ * Interface for providing bearer tokens for authentication.
+ *
+ * <p>Implementations can provide tokens from various sources such as:
+ *
+ * <ul>
+ *   <li>Static string values
+ *   <li>Files (with automatic reloading)
+ *   <li>External token services
+ * </ul>
+ */
+public interface BearerTokenProvider extends AutoCloseable {
+
+  /**
+   * Get the current bearer token.
+   *
+   * @return the bearer token, or null if no token is available
+   */
+  @Nullable
+  String getToken();
+
+  /**
+   * Clean up any resources used by this token provider. Should be called when the provider is no
+   * longer needed.
+   */
+  @Override
+  default void close() {
+    // Default implementation does nothing
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java
new file mode 100644
index 000000000..2d72f54f7
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProvider.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.exceptions.JWTDecodeException;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import jakarta.annotation.Nullable;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+import org.apache.polaris.nosql.async.AsyncExec;
+import org.apache.polaris.nosql.async.Cancelable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A token provider that reads tokens from a file and automatically reloads them based on a
+ * configurable refresh interval or JWT expiration timing.
+ *
+ * <p>This is particularly useful in Kubernetes environments where tokens are mounted as files and
+ * refreshed by external systems (e.g., service account tokens, projected volumes, etc.).
+ *
+ * <p>The token file is expected to contain the bearer token as plain text. Leading and trailing
+ * whitespace will be trimmed.
+ *
+ * <p>If JWT expiration refresh is enabled and the token is a valid JWT with an 'exp' claim, the
+ * provider will automatically refresh the token based on the expiration time minus a configurable
+ * buffer, rather than using the fixed refresh interval.
+ */
+public class FileBearerTokenProvider implements BearerTokenProvider {
+
+  private static final Logger logger = LoggerFactory.getLogger(FileBearerTokenProvider.class);
+
+  private final Path tokenFilePath;
+  private final Duration refreshInterval;
+  private final boolean jwtExpirationRefresh;
+  private final Duration jwtExpirationBuffer;
+  private final Supplier<Instant> clock;
+  private final AtomicBoolean refreshLock = new AtomicBoolean();
+  private final AsyncExec asyncExec;
+  private final CompletableFuture<String> initialTokenFuture = new CompletableFuture<>();
+  private final long initialTokenWaitMillis;
+
+  private volatile String cachedToken;
+  private volatile Instant lastRefresh;
+  private volatile Instant nextRefresh;
+  private volatile Cancelable<?> refreshTask;
+
+  /**
+   * Create a new file-based token provider with JWT expiration support.
+   *
+   * @param tokenFilePath path to the file containing the bearer token
+   * @param refreshInterval how often to check for token file changes (fallback for non-JWT tokens)
+   * @param jwtExpirationRefresh whether to use JWT expiration for refresh timing
+   * @param jwtExpirationBuffer buffer time before JWT expiration to refresh the token
+   * @param clock clock instance for time operations
+   * @throws IllegalStateException if the initial token cannot be loaded from the file
+   */
+  public FileBearerTokenProvider(
+      Path tokenFilePath,
+      Duration refreshInterval,
+      boolean jwtExpirationRefresh,
+      Duration jwtExpirationBuffer,
+      Duration initialTokenWait,
+      AsyncExec asyncExec,
+      Supplier<Instant> clock) {
+    this.tokenFilePath = tokenFilePath;
+    this.refreshInterval = refreshInterval;
+    this.jwtExpirationRefresh = jwtExpirationRefresh;
+    this.jwtExpirationBuffer = jwtExpirationBuffer;
+    this.initialTokenWaitMillis = initialTokenWait.toMillis();
+    this.clock = clock;
+    this.asyncExec = asyncExec;
+
+    this.nextRefresh = Instant.MIN;
+    this.lastRefresh = Instant.MIN;
+    // start refreshing the token (immediately)
+    scheduleRefreshAttempt(Duration.ZERO);
+
+    checkState(Files.isReadable(tokenFilePath), "OPA token file does not exist or is not readable");
+
+    logger.debug(
+        "Created file token provider for path: {} with refresh interval: {}, JWT expiration refresh: {}, JWT buffer: {}, next refresh: {}",
+        tokenFilePath,
+        refreshInterval,
+        jwtExpirationRefresh,
+        jwtExpirationBuffer,
+        nextRefresh);
+  }
+
+  @Override
+  public String getToken() {
+    String token = cachedToken;
+    if (token != null) {
+      // Regular case, we have a cached token
+      return token;
+    }
+    // We get here if the cached token is null, which means that the initial token
+    // has not been loaded yet.
+    // In this case we wait for the configured amount of time
+    // (5 seconds in production, much lower in tests).
+    try {
+      return initialTokenFuture.get(initialTokenWaitMillis, TimeUnit.MILLISECONDS);
+    } catch (Exception e) {
+      throw new IllegalStateException("Failed to read initial OPA bearer token", e);
+    }
+  }
+
+  @Override
+  public void close() {
+    cachedToken = null;
+    Cancelable<?> task = refreshTask;
+    if (task != null) {
+      refreshTask.cancel();
+    }
+  }
+
+  private void refreshTokenAttempt() {
+    boolean isInitialRefresh = cachedToken == null;
+    Duration delay;
+    if (doRefreshToken()) {
+      delay = Duration.between(clock.get(), nextRefresh);
+      if (isInitialRefresh) {
+        // If we have never cached a token, complete the initial token-future to "unblock"
+        // getToken() call sites waiting for it.
+        initialTokenFuture.complete(cachedToken);
+      }
+    } else {
+      // Token refresh did not succeed, retry soon
+      delay = Duration.ofSeconds(1); // TODO: make configurable
+    }
+    scheduleRefreshAttempt(delay);
+  }
+
+  private void scheduleRefreshAttempt(Duration delay) {
+    this.refreshTask = asyncExec.schedule(this::refreshTokenAttempt, delay);
+  }
+
+  private boolean doRefreshToken() {
+    String newToken = loadTokenFromFile();
+    // Only update cached token if we successfully loaded a new one
+    if (newToken == null) {
+      logger.debug("Couldn't load new bearer token from {}, will retry.", tokenFilePath);
+      return false;
+    }
+    cachedToken = newToken;
+
+    lastRefresh = clock.get();
+
+    // Calculate next refresh time based on current token (may be cached)
+    nextRefresh = calculateNextRefresh(cachedToken);
+
+    logger.debug(
+        "Token refreshed from file: {} (token present: {}), next refresh: {}",
+        tokenFilePath,
+        cachedToken != null,
+        nextRefresh);
+
+    return true;
+  }
+
+  /** Calculate when the next refresh should occur based on JWT expiration or fixed interval. */
+  private Instant calculateNextRefresh(String token) {
+    if (!jwtExpirationRefresh) {
+      return lastRefresh.plus(refreshInterval);
+    }
+
+    // Attempt to parse as JWT and extract expiration
+    Optional<Instant> expiration = getJwtExpirationTime(token);
+
+    if (expiration.isPresent()) {
+      // Refresh before expiration minus buffer
+      Instant refreshTime = expiration.get().minus(jwtExpirationBuffer);
+
+      // Ensure refresh time is in the future and not too soon (at least 1 second)
+      Instant minRefreshTime = clock.get().plus(Duration.ofSeconds(1));
+      if (refreshTime.isBefore(minRefreshTime)) {
+        logger.warn(
+            "JWT expires too soon ({}), using minimum refresh interval instead", expiration.get());
+        return lastRefresh.plus(refreshInterval);
+      }
+
+      logger.debug(
+          "Using JWT expiration-based refresh: token expires at {}, refreshing at {}",
+          expiration.get(),
+          refreshTime);
+      return refreshTime;
+    }
+
+    // Fall back to fixed interval (token is not a valid JWT or has no expiration)
+    logger.debug("Token is not a valid JWT or has no expiration, using fixed refresh interval");
+    return lastRefresh.plus(refreshInterval);
+  }
+
+  @Nullable
+  private String loadTokenFromFile() {
+    try {
+      String token = Files.readString(tokenFilePath, StandardCharsets.UTF_8).trim();
+      if (!token.isEmpty()) {
+        return token;
+      }
+    } catch (IOException e) {
+      logger.debug("Failed to read token from file", e);
+    }
+    return null;
+  }
+
+  /**
+   * Extract the expiration time from a JWT token without signature verification.
+   *
+   * @param token the JWT token string
+   * @return the expiration time as an Instant, or empty if not present or invalid
+   */
+  private Optional<Instant> getJwtExpirationTime(String token) {
+    try {
+      DecodedJWT decodedJWT = JWT.decode(token);
+      Date expiresAt = decodedJWT.getExpiresAt();
+      return expiresAt != null ? Optional.of(expiresAt.toInstant()) : Optional.empty();
+    } catch (JWTDecodeException e) {
+      logger.debug("Failed to decode JWT token: {}", e.getMessage());
+      return Optional.empty();
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java
new file mode 100644
index 000000000..0fd8663df
--- /dev/null
+++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProvider.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+
+/** A simple token provider that returns a static string value. */
+public class StaticBearerTokenProvider implements BearerTokenProvider {
+
+  private final String token;
+
+  public StaticBearerTokenProvider(String token) {
+    checkArgument(!Strings.isNullOrEmpty(token), "Token cannot be null or empty");
+    this.token = token;
+  }
+
+  @Override
+  public String getToken() {
+    return token;
+  }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java
new file mode 100644
index 000000000..ee4d3d0bd
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaHttpClientFactoryTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for OpaHttpClientFactory. */
+public class OpaHttpClientFactoryTest {
+
+  @Test
+  void testCreateHttpClientWithHttpUrl() throws Exception {
+    OpaAuthorizationConfig.HttpConfig httpConfig =
+        ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(true).build();
+
+    try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) {
+      assertThat(client).isNotNull();
+    }
+  }
+
+  @Test
+  void testCreateHttpClientWithHttpsUrl() throws Exception {
+    OpaAuthorizationConfig.HttpConfig httpConfig =
+        ImmutableHttpConfig.builder().timeout(Duration.ofSeconds(5)).verifySsl(false).build();
+
+    try (CloseableHttpClient client = OpaHttpClientFactory.createHttpClient(httpConfig)) {
+      assertThat(client).isNotNull();
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
new file mode 100644
index 000000000..982d81b98
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Duration;
+import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider;
+import org.apache.polaris.nosql.async.java.JavaPoolAsyncExec;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class OpaPolarisAuthorizerFactoryTest {
+
+  @TempDir Path tempDir;
+
+  @Test
+  public void testFactoryWithStaticTokenConfiguration() {
+    // Build configuration for static token
+    OpaAuthorizationConfig opaConfig =
+        ImmutableOpaAuthorizationConfig.builder()
+            .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+            .auth(
+                ImmutableAuthenticationConfig.builder()
+                    .type(OpaAuthorizationConfig.AuthenticationType.BEARER)
+                    .bearer(
+                        ImmutableBearerTokenConfig.builder()
+                            .staticToken(
+                                ImmutableStaticTokenConfig.builder()
+                                    .value("static-token-value")
+                                    .build())
+                            .build())
+                    .build())
+            .http(
+                ImmutableHttpConfig.builder()
+                    .timeout(Duration.ofSeconds(2))
+                    .verifySsl(true)
+                    .build())
+            .build();
+
+    try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      OpaPolarisAuthorizerFactory factory =
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+      // Create authorizer
+      RealmConfig realmConfig = mock(RealmConfig.class);
+      OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+      assertThat(authorizer).isNotNull();
+    }
+  }
+
+  @Test
+  public void testFactoryWithFileBasedTokenConfiguration() throws IOException {
+    // Create a temporary token file
+    Path tokenFile = tempDir.resolve("bearer-token.txt");
+    String tokenValue = "file-based-token-value";
+    Files.writeString(tokenFile, tokenValue);
+
+    // Build configuration for file-based token
+    OpaAuthorizationConfig opaConfig =
+        ImmutableOpaAuthorizationConfig.builder()
+            .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+            .auth(
+                ImmutableAuthenticationConfig.builder()
+                    .type(OpaAuthorizationConfig.AuthenticationType.BEARER)
+                    .bearer(
+                        ImmutableBearerTokenConfig.builder()
+                            .fileBased(
+                                ImmutableFileBasedConfig.builder()
+                                    .path(tokenFile)
+                                    .refreshInterval(Duration.ofMinutes(5))
+                                    .jwtExpirationRefresh(true)
+                                    .jwtExpirationBuffer(Duration.ofMinutes(1))
+                                    .build())
+                            .build())
+                    .build())
+            .http(
+                ImmutableHttpConfig.builder()
+                    .timeout(Duration.ofSeconds(2))
+                    .verifySsl(true)
+                    .build())
+            .build();
+
+    try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      OpaPolarisAuthorizerFactory factory =
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+      // Create authorizer
+      RealmConfig realmConfig = mock(RealmConfig.class);
+      OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+      assertThat(authorizer).isNotNull();
+
+      // Also verify that the token provider actually reads from the file
+      try (FileBearerTokenProvider provider =
+          new FileBearerTokenProvider(
+              tokenFile,
+              Duration.ofMinutes(5),
+              true,
+              Duration.ofMinutes(1),
+              Duration.ofSeconds(10),
+              asyncExec,
+              Clock.systemUTC()::instant)) {
+
+        String actualToken = provider.getToken();
+        assertThat(actualToken).isEqualTo(tokenValue);
+      }
+    }
+  }
+
+  @Test
+  public void testFactoryWithNoTokenConfiguration() {
+    // Build configuration with no authentication
+    OpaAuthorizationConfig opaConfig =
+        ImmutableOpaAuthorizationConfig.builder()
+            .policyUri(URI.create("http://localhost:8181/v1/data/polaris/authz/allow"))
+            .auth(
+                ImmutableAuthenticationConfig.builder()
+                    .type(OpaAuthorizationConfig.AuthenticationType.NONE)
+                    .build())
+            .http(
+                ImmutableHttpConfig.builder()
+                    .timeout(Duration.ofSeconds(2))
+                    .verifySsl(true)
+                    .build())
+            .build();
+
+    try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      OpaPolarisAuthorizerFactory factory =
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+
+      // Create authorizer
+      RealmConfig realmConfig = mock(RealmConfig.class);
+      OpaPolarisAuthorizer authorizer = (OpaPolarisAuthorizer) factory.create(realmConfig);
+
+      assertThat(authorizer).isNotNull();
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
new file mode 100644
index 000000000..1c359ade2
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
@@ -0,0 +1,663 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import org.apache.polaris.core.entity.PolarisEntity;
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.core.persistence.ResolvedPolarisEntity;
+import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
+import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Unit tests for OpaPolarisAuthorizer including basic functionality and bearer token authentication
+ */
+public class OpaPolarisAuthorizerTest {
+
+  @Test
+  void testOpaInputJsonFormat() throws Exception {
+    // Capture the request body for verification
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      // Use the dynamically assigned port from the local server
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+      PolarisPrincipal principal =
+          PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor"));
+
+      Set<PolarisBaseEntity> entities = Set.of();
+      PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of());
+      PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of());
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      entities,
+                      PolarisAuthorizableOperation.LOAD_VIEW,
+                      target,
+                      secondary));
+
+      // Parse and verify JSON structure from captured request
+      ObjectMapper mapper = new ObjectMapper();
+      JsonNode root = mapper.readTree(capturedRequestBody[0]);
+      assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+      var input = root.get("input");
+      assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+      assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+      assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+      assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testOpaRequestJsonWithHierarchicalResource() throws Exception {
+    // Capture the request body for verification
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+      // Set up a realistic principal
+      PolarisPrincipal principal =
+          PolarisPrincipal.of(
+              "alice",
+              Map.of("department", "analytics", "level", "senior"),
+              Set.of("data_engineer", "analyst"));
+
+      // Create a hierarchical resource structure: catalog.namespace.table
+      // Create catalog entity using builder pattern
+      PolarisEntity catalogEntity =
+          new PolarisEntity.Builder()
+              .setName("prod_catalog")
+              .setType(PolarisEntityType.CATALOG)
+              .setId(100L)
+              .setCatalogId(100L)
+              .setParentId(0L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create namespace entity using builder pattern
+      PolarisEntity namespaceEntity =
+          new PolarisEntity.Builder()
+              .setName("sales_data")
+              .setType(PolarisEntityType.NAMESPACE)
+              .setId(200L)
+              .setCatalogId(100L)
+              .setParentId(100L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create table entity using builder pattern
+      PolarisEntity tableEntity =
+          new PolarisEntity.Builder()
+              .setName("customer_orders")
+              .setType(PolarisEntityType.TABLE_LIKE)
+              .setId(300L)
+              .setCatalogId(100L)
+              .setParentId(200L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create hierarchical path: catalog -> namespace -> table
+      // Build a realistic resolved path using ResolvedPolarisEntity objects
+      List<ResolvedPolarisEntity> resolvedPath =
+          List.of(
+              createResolvedEntity(catalogEntity),
+              createResolvedEntity(namespaceEntity),
+              createResolvedEntity(tableEntity));
+      PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath);
+
+      Set<PolarisBaseEntity> entities = Set.of(catalogEntity, namespaceEntity, tableEntity);
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      entities,
+                      PolarisAuthorizableOperation.LOAD_TABLE,
+                      tablePath,
+                      null));
+
+      // Parse and verify the complete JSON structure
+      ObjectMapper mapper = new ObjectMapper();
+      JsonNode root = mapper.readTree(capturedRequestBody[0]);
+
+      // Verify top-level structure
+      assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+      var input = root.get("input");
+      assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+      assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+      assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+      assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+
+      // Verify actor details
+      var actor = input.get("actor");
+      assertThat(actor.has("principal")).as("Actor should have 'principal' field").isTrue();
+      assertThat(actor.get("principal").asText()).isEqualTo("alice");
+      assertThat(actor.has("roles")).as("Actor should have 'roles' field").isTrue();
+      assertThat(actor.get("roles").isArray()).as("Roles should be an array").isTrue();
+      assertThat(actor.get("roles").size()).isEqualTo(2);
+
+      // Verify action
+      var action = input.get("action");
+      assertThat(action.asText()).isEqualTo("LOAD_TABLE");
+
+      // Verify resource structure - this is the key part for hierarchical resources
+      var resource = input.get("resource");
+      assertThat(resource.has("targets")).as("Resource should have 'targets' field").isTrue();
+      assertThat(resource.has("secondaries"))
+          .as("Resource should have 'secondaries' field")
+          .isTrue();
+
+      var targets = resource.get("targets");
+      assertThat(targets.isArray()).as("Targets should be an array").isTrue();
+      assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1);
+
+      var target = targets.get(0);
+      // Verify the target entity (table) details
+      assertThat(target.isObject()).as("Target should be an object").isTrue();
+      assertThat(target.has("type")).as("Target should have 'type' field").isTrue();
+      assertThat(target.get("type").asText())
+          .as("Target type should be TABLE_LIKE")
+          .isEqualTo("TABLE_LIKE");
+      assertThat(target.has("name")).as("Target should have 'name' field").isTrue();
+      assertThat(target.get("name").asText())
+          .as("Target name should be customer_orders")
+          .isEqualTo("customer_orders");
+
+      // Verify the hierarchical parents array
+      assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue();
+      var parents = target.get("parents");
+      assertThat(parents.isArray()).as("Parents should be an array").isTrue();
+      assertThat(parents.size()).as("Should have 2 parents (catalog and namespace)").isEqualTo(2);
+
+      // Verify catalog parent (first in the hierarchy)
+      var catalogParent = parents.get(0);
+      assertThat(catalogParent.get("type").asText())
+          .as("First parent should be catalog")
+          .isEqualTo("CATALOG");
+      assertThat(catalogParent.get("name").asText())
+          .as("Catalog name should be prod_catalog")
+          .isEqualTo("prod_catalog");
+
+      // Verify namespace parent (second in the hierarchy)
+      var namespaceParent = parents.get(1);
+      assertThat(namespaceParent.get("type").asText())
+          .as("Second parent should be namespace")
+          .isEqualTo("NAMESPACE");
+      assertThat(namespaceParent.get("name").asText())
+          .as("Namespace name should be sales_data")
+          .isEqualTo("sales_data");
+
+      var secondaries = resource.get("secondaries");
+      assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue();
+      assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0);
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testOpaRequestJsonWithMultiLevelNamespace() throws Exception {
+    // Capture the request body for verification
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+      // Set up a realistic principal
+      PolarisPrincipal principal =
+          PolarisPrincipal.of(
+              "bob",
+              Map.of("team", "ml", "project", "forecasting"),
+              Set.of("data_scientist", "analyst"));
+
+      // Create a multi-level namespace structure: catalog.department.team.table
+      // Create catalog entity
+      PolarisEntity catalogEntity =
+          new PolarisEntity.Builder()
+              .setName("analytics_catalog")
+              .setType(PolarisEntityType.CATALOG)
+              .setId(100L)
+              .setCatalogId(100L)
+              .setParentId(0L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create first-level namespace entity (department)
+      PolarisEntity departmentEntity =
+          new PolarisEntity.Builder()
+              .setName("engineering")
+              .setType(PolarisEntityType.NAMESPACE)
+              .setId(200L)
+              .setCatalogId(100L)
+              .setParentId(100L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create second-level namespace entity (team)
+      PolarisEntity teamEntity =
+          new PolarisEntity.Builder()
+              .setName("machine_learning")
+              .setType(PolarisEntityType.NAMESPACE)
+              .setId(300L)
+              .setCatalogId(100L)
+              .setParentId(200L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create table entity
+      PolarisEntity tableEntity =
+          new PolarisEntity.Builder()
+              .setName("feature_store")
+              .setType(PolarisEntityType.TABLE_LIKE)
+              .setId(400L)
+              .setCatalogId(100L)
+              .setParentId(300L)
+              .setCreateTimestamp(System.currentTimeMillis())
+              .build();
+
+      // Create hierarchical path: catalog -> department -> team -> table
+      List<ResolvedPolarisEntity> resolvedPath =
+          List.of(
+              createResolvedEntity(catalogEntity),
+              createResolvedEntity(departmentEntity),
+              createResolvedEntity(teamEntity),
+              createResolvedEntity(tableEntity));
+      PolarisResolvedPathWrapper tablePath = new PolarisResolvedPathWrapper(resolvedPath);
+
+      Set<PolarisBaseEntity> entities =
+          Set.of(catalogEntity, departmentEntity, teamEntity, tableEntity);
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      entities,
+                      PolarisAuthorizableOperation.LOAD_TABLE,
+                      tablePath,
+                      null));
+
+      // Parse and verify the complete JSON structure
+      ObjectMapper mapper = new ObjectMapper();
+      JsonNode root = mapper.readTree(capturedRequestBody[0]);
+
+      // Verify top-level structure
+      assertThat(root.has("input")).as("Root should have 'input' field").isTrue();
+      var input = root.get("input");
+      assertThat(input.has("actor")).as("Input should have 'actor' field").isTrue();
+      assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
+      assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
+      assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+
+      // Verify actor details
+      var actor = input.get("actor");
+      assertThat(actor.get("principal").asText()).isEqualTo("bob");
+      assertThat(actor.get("roles").size()).isEqualTo(2);
+
+      // Verify action
+      var action = input.get("action");
+      assertThat(action.asText()).isEqualTo("LOAD_TABLE");
+
+      // Verify resource structure with multi-level namespace hierarchy
+      var resource = input.get("resource");
+      var targets = resource.get("targets");
+      assertThat(targets.size()).as("Should have exactly one target").isEqualTo(1);
+
+      var target = targets.get(0);
+      // Verify the target entity (table) details
+      assertThat(target.get("type").asText())
+          .as("Target type should be TABLE_LIKE")
+          .isEqualTo("TABLE_LIKE");
+      assertThat(target.get("name").asText())
+          .as("Target name should be feature_store")
+          .isEqualTo("feature_store");
+
+      // Verify the multi-level hierarchical parents array
+      assertThat(target.has("parents")).as("Target should have 'parents' field").isTrue();
+      var parents = target.get("parents");
+      assertThat(parents.isArray()).as("Parents should be an array").isTrue();
+      assertThat(parents.size())
+          .as("Should have 3 parents (catalog, department, team)")
+          .isEqualTo(3);
+
+      // Verify catalog parent (first in the hierarchy)
+      var catalogParent = parents.get(0);
+      assertThat(catalogParent.get("type").asText())
+          .as("First parent should be catalog")
+          .isEqualTo("CATALOG");
+      assertThat(catalogParent.get("name").asText())
+          .as("Catalog name should be analytics_catalog")
+          .isEqualTo("analytics_catalog");
+
+      // Verify department namespace parent (second in the hierarchy)
+      var departmentParent = parents.get(1);
+      assertThat(departmentParent.get("type").asText())
+          .as("Second parent should be namespace")
+          .isEqualTo("NAMESPACE");
+      assertThat(departmentParent.get("name").asText())
+          .as("Department name should be engineering")
+          .isEqualTo("engineering");
+
+      // Verify team namespace parent (third in the hierarchy)
+      var teamParent = parents.get(2);
+      assertThat(teamParent.get("type").asText())
+          .as("Third parent should be namespace")
+          .isEqualTo("NAMESPACE");
+      assertThat(teamParent.get("name").asText())
+          .as("Team name should be machine_learning")
+          .isEqualTo("machine_learning");
+
+      var secondaries = resource.get("secondaries");
+      assertThat(secondaries.isArray()).as("Secondaries should be an array").isTrue();
+      assertThat(secondaries.size()).as("Should have no secondaries in this test").isEqualTo(0);
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void testAuthorizeOrThrowWithEmptyTargetsAndSecondaries() throws Exception {
+    HttpServer server = createServerWithAllowResponse();
+    try {
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri, HttpClients.createDefault(), new ObjectMapper(), null);
+
+      PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
+
+      Set<PolarisBaseEntity> entities = Set.of();
+
+      PolarisResolvedPathWrapper target = new PolarisResolvedPathWrapper(List.of());
+      PolarisResolvedPathWrapper secondary = new PolarisResolvedPathWrapper(List.of());
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      entities,
+                      PolarisAuthorizableOperation.CREATE_CATALOG,
+                      target,
+                      secondary));
+
+      // Test multiple targets
+      PolarisResolvedPathWrapper target1 = new PolarisResolvedPathWrapper(List.of());
+      PolarisResolvedPathWrapper target2 = new PolarisResolvedPathWrapper(List.of());
+      List<PolarisResolvedPathWrapper> targets = List.of(target1, target2);
+      List<PolarisResolvedPathWrapper> secondaries = List.of();
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      entities,
+                      PolarisAuthorizableOperation.LOAD_VIEW,
+                      targets,
+                      secondaries));
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  public void testCreateWithHttpsAndBearerToken() {
+    // Test that OpaPolarisAuthorizer can be created with HTTPS URLs and bearer tokens
+    BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token");
+    URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+    OpaPolarisAuthorizer authorizer =
+        new OpaPolarisAuthorizer(
+            policyUri, HttpClients.createDefault(), new ObjectMapper(), tokenProvider);
+
+    assertThat(authorizer).isNotNull();
+  }
+
+  @Test
+  public void testBearerTokenIsAddedToHttpRequest() throws IOException {
+    URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+    CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
+    CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
+    HttpEntity mockEntity = mock(HttpEntity.class);
+
+    when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse);
+    when(mockResponse.getCode()).thenReturn(200);
+    when(mockResponse.getEntity()).thenReturn(mockEntity);
+    when(mockEntity.getContent())
+        .thenReturn(
+            new ByteArrayInputStream(
+                "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8)));
+
+    BearerTokenProvider tokenProvider = new StaticBearerTokenProvider("test-bearer-token");
+    OpaPolarisAuthorizer authorizer =
+        new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider);
+
+    PolarisPrincipal mockPrincipal =
+        PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet());
+
+    PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE;
+    assertThatNoException()
+        .isThrownBy(
+            () -> {
+              authorizer.authorizeOrThrow(
+                  mockPrincipal,
+                  Collections.emptySet(),
+                  mockOperation,
+                  (PolarisResolvedPathWrapper) null,
+                  (PolarisResolvedPathWrapper) null);
+            });
+
+    // Verify the Authorization header with static bearer token
+    verifyAuthorizationHeader(mockHttpClient, "test-bearer-token");
+  }
+
+  @Test
+  public void testBearerTokenFromBearerTokenProvider() throws IOException {
+    // Mock HTTP client and response
+    CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
+    CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
+    HttpEntity mockEntity = mock(HttpEntity.class);
+
+    when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse);
+    when(mockResponse.getCode()).thenReturn(200);
+    when(mockResponse.getEntity()).thenReturn(mockEntity);
+    when(mockEntity.getContent())
+        .thenReturn(
+            new ByteArrayInputStream(
+                "{\"result\":{\"allow\":true}}".getBytes(StandardCharsets.UTF_8)));
+
+    // Create token provider that returns a dynamic token
+    BearerTokenProvider tokenProvider = () -> "dynamic-token-12345";
+    URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
+    // Create authorizer with the token provider instead of static token
+    OpaPolarisAuthorizer authorizer =
+        new OpaPolarisAuthorizer(policyUri, mockHttpClient, new ObjectMapper(), tokenProvider);
+
+    // Create mock principal and entities
+    PolarisPrincipal mockPrincipal =
+        PolarisPrincipal.of("test-user", Map.of(), Collections.emptySet());
+
+    PolarisAuthorizableOperation mockOperation = PolarisAuthorizableOperation.LOAD_TABLE;
+
+    // Execute authorization (should not throw since we mocked allow=true)
+    assertThatNoException()
+        .isThrownBy(
+            () -> {
+              authorizer.authorizeOrThrow(
+                  mockPrincipal,
+                  Collections.emptySet(),
+                  mockOperation,
+                  (PolarisResolvedPathWrapper) null,
+                  (PolarisResolvedPathWrapper) null);
+            });
+
+    // Verify the Authorization header with bearer token from provider
+    verifyAuthorizationHeader(mockHttpClient, "dynamic-token-12345");
+  }
+
+  private ResolvedPolarisEntity createResolvedEntity(PolarisEntity entity) {
+    return new ResolvedPolarisEntity(entity, List.of(), List.of());
+  }
+
+  /**
+   * Helper method to create and start an HTTP server that captures request bodies.
+   *
+   * @param capturedRequestBody Array to store the captured request body
+   * @return Started HttpServer instance
+   */
+  private HttpServer createServerWithRequestCapture(String[] capturedRequestBody)
+      throws IOException {
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/v1/data/polaris/allow",
+        new HttpHandler() {
+          @Override
+          public void handle(HttpExchange exchange) throws IOException {
+            // Capture request body
+            byte[] requestBytes = exchange.getRequestBody().readAllBytes();
+            capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8);
+
+            String response = "{\"result\":{\"allow\":true}}";
+            exchange.getResponseHeaders().add("Content-Type", "application/json");
+            exchange.sendResponseHeaders(200, response.length());
+            try (OutputStream os = exchange.getResponseBody()) {
+              os.write(response.getBytes(StandardCharsets.UTF_8));
+            }
+          }
+        });
+    server.start();
+    return server;
+  }
+
+  /**
+   * Helper method to create and start an HTTP server that returns a simple allow response.
+   *
+   * @return Started HttpServer instance
+   */
+  private HttpServer createServerWithAllowResponse() throws IOException {
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/v1/data/polaris/allow",
+        new HttpHandler() {
+          @Override
+          public void handle(HttpExchange exchange) throws IOException {
+            String response = "{\"result\":{\"allow\":true}}";
+            exchange.getResponseHeaders().add("Content-Type", "application/json");
+            exchange.sendResponseHeaders(200, response.length());
+            try (OutputStream os = exchange.getResponseBody()) {
+              os.write(response.getBytes(StandardCharsets.UTF_8));
+            }
+          }
+        });
+    server.start();
+    return server;
+  }
+
+  /**
+   * Helper method to capture and verify HTTP request Authorization header.
+   *
+   * @param mockHttpClient The mocked HTTP client to verify against
+   * @param expectedToken The expected bearer token value, or null if no Authorization header
+   *     expected
+   */
+  private void verifyAuthorizationHeader(CloseableHttpClient mockHttpClient, String expectedToken)
+      throws IOException {
+    // Capture the HTTP request to verify bearer token header
+    ArgumentCaptor<HttpPost> httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class);
+    verify(mockHttpClient).execute(httpPostCaptor.capture());
+
+    HttpPost capturedRequest = httpPostCaptor.getValue();
+
+    if (expectedToken != null) {
+      // Verify the Authorization header is present and contains the expected token
+      assertThat(capturedRequest.containsHeader("Authorization"))
+          .as("Authorization header should be present when bearer token is provided")
+          .isTrue();
+      String authHeader = capturedRequest.getFirstHeader("Authorization").getValue();
+      assertThat(authHeader)
+          .as("Authorization header should contain the correct bearer token")
+          .isEqualTo("Bearer " + expectedToken);
+    } else {
+      // Verify no Authorization header is present when token is null
+      assertThat(capturedRequest.containsHeader("Authorization"))
+          .as("Authorization header should not be present when token provider returns null")
+          .isFalse();
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java
new file mode 100644
index 000000000..1651dc5e3
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/FileBearerTokenProviderTest.java
@@ -0,0 +1,471 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.ids.mocks.MutableMonotonicClock;
+import org.apache.polaris.nosql.async.MockAsyncExec;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class FileBearerTokenProviderTest {
+
+  @TempDir Path tempDir;
+
+  @Test
+  public void testInitialRefresh() throws IOException {
+    // Create a temporary token file
+    Path tokenFile = tempDir.resolve("token.txt");
+    Files.writeString(tokenFile, "");
+
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create file token provider
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMinutes(5),
+            true,
+            Duration.ofMinutes(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+
+      // initial refresh has not happened yet, getToken() times out waiting for the initial token
+      assertThatIllegalStateException()
+          .isThrownBy(provider::getToken)
+          .withMessage("Failed to read initial OPA bearer token");
+
+      // initial refresh should have been scheduled, run it
+      assertThat(asyncExec.readyCount()).isEqualTo(1);
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+      assertThat(asyncExec.readyCount()).isEqualTo(0);
+
+      // Token file is still empty, getToken() still times out waiting for the initial token
+      assertThatIllegalStateException()
+          .isThrownBy(provider::getToken)
+          .withMessage("Failed to read initial OPA bearer token");
+
+      monotonicClock.advanceBoth(Duration.ofSeconds(1));
+      // refresh should have been scheduled, run it
+      assertThat(asyncExec.readyCount()).isEqualTo(1);
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+      assertThat(asyncExec.readyCount()).isEqualTo(0);
+
+      // Token file is still empty, getToken() still times out waiting for the initial token
+      assertThatIllegalStateException()
+          .isThrownBy(provider::getToken)
+          .withMessage("Failed to read initial OPA bearer token");
+
+      String expectedToken = "test-bearer-token-123";
+      Files.writeString(tokenFile, expectedToken);
+
+      monotonicClock.advanceBoth(Duration.ofSeconds(1));
+      // refresh should have been scheduled, run it
+      assertThat(asyncExec.readyCount()).isEqualTo(1);
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test token retrieval
+      String actualToken = provider.getToken();
+      assertThat(actualToken).isEqualTo(expectedToken);
+    }
+  }
+
+  @Test
+  public void testLoadTokenFromFileWithWhitespace() throws IOException {
+    // Create a temporary token file with whitespace
+    Path tokenFile = tempDir.resolve("token.txt");
+    String tokenWithWhitespace = "  test-bearer-token-456  \n\t";
+    String expectedToken = "test-bearer-token-456";
+    Files.writeString(tokenFile, tokenWithWhitespace);
+
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create file token provider
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMinutes(5),
+            true,
+            Duration.ofMinutes(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test token retrieval (should trim whitespace)
+      String actualToken = provider.getToken();
+      assertThat(actualToken).isEqualTo(expectedToken);
+    }
+  }
+
+  @Test
+  public void testTokenRefresh() throws IOException {
+    // Create a temporary token file
+    Path tokenFile = tempDir.resolve("token.txt");
+    String initialToken = "initial-token";
+    Files.writeString(tokenFile, initialToken);
+
+    // Create mutable clock for deterministic time control
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create file token provider with short refresh interval
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMillis(100),
+            false,
+            Duration.ofMinutes(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test initial token
+      String token1 = provider.getToken();
+      assertThat(token1).isEqualTo(initialToken);
+
+      // Advance time past refresh interval
+      monotonicClock.advanceBoth(Duration.ofMillis(200));
+
+      // Update the file
+      String updatedToken = "updated-token";
+      Files.writeString(tokenFile, updatedToken);
+
+      // refresh task didn't run yet, so token should still be the same
+      assertThat(token1).isEqualTo(initialToken);
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test that token is refreshed
+      String token2 = provider.getToken();
+      assertThat(token2).isEqualTo(updatedToken);
+    }
+  }
+
+  @Test
+  public void testNonExistentFileThrows() {
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Constructor should throw exception when token file doesn't exist
+    assertThatThrownBy(
+            () ->
+                new FileBearerTokenProvider(
+                        Paths.get("/non/existent/file.txt"),
+                        Duration.ofMinutes(5),
+                        true,
+                        Duration.ofMinutes(1),
+                        Duration.ofMillis(1),
+                        asyncExec,
+                        monotonicClock::currentInstant)
+                    .close())
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("OPA token file does not exist or is not readable");
+  }
+
+  @Test
+  public void testJwtExpirationRefresh() throws IOException {
+    // Create mutable clock for deterministic time control
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create a temporary token file with a JWT that expires in 10 seconds from clock time
+    Path tokenFile = tempDir.resolve("jwt-token.txt");
+    String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(10));
+    Files.writeString(tokenFile, jwtToken);
+
+    // Create file token provider with JWT expiration refresh enabled
+    // Buffer of 3 seconds means it should refresh 3 seconds before expiration (at 7 seconds)
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMinutes(10),
+            true,
+            Duration.ofSeconds(3),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test initial token
+      String token1 = provider.getToken();
+      assertThat(token1).isEqualTo(jwtToken);
+
+      // Advance time by 7.1 seconds (should trigger refresh due to 3 second buffer)
+      monotonicClock.advanceBoth(Duration.ofMillis(7100));
+
+      // Update the file with a new JWT
+      String newJwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(20));
+      Files.writeString(tokenFile, newJwtToken);
+
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test that token is refreshed
+      String token2 = provider.getToken();
+      assertThat(token2).isEqualTo(newJwtToken);
+    }
+  }
+
+  @Test
+  public void testJwtExpirationRefreshDisabled() throws IOException {
+    // Create mutable clock for deterministic time control
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create a temporary token file with a JWT that expires in 1 second from clock time
+    Path tokenFile = tempDir.resolve("jwt-token.txt");
+    String jwtToken = createJwtWithExpiration(monotonicClock.currentInstant().plusSeconds(1));
+    Files.writeString(tokenFile, jwtToken);
+
+    // Create file token provider with JWT expiration refresh disabled
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMillis(100),
+            false,
+            Duration.ofSeconds(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test initial token
+      String token1 = provider.getToken();
+      assertThat(token1).isEqualTo(jwtToken);
+
+      // Advance time past fixed refresh interval (150ms)
+      monotonicClock.advanceBoth(Duration.ofMillis(150));
+
+      // Update the file
+      String newToken = "updated-non-jwt-token";
+      Files.writeString(tokenFile, newToken);
+
+      // refresh task didn't run yet, so token should still be the same
+      assertThat(provider.getToken()).isEqualTo(token1);
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test that token is refreshed based on fixed interval, not JWT expiration
+      String token2 = provider.getToken();
+      assertThat(token2).isEqualTo(newToken);
+    }
+  }
+
+  @Test
+  public void testNonJwtTokenWithJwtRefreshEnabled() throws IOException {
+    // Create mutable clock for deterministic time control
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create a temporary token file with a non-JWT token
+    Path tokenFile = tempDir.resolve("token.txt");
+    String nonJwtToken = "plain-text-token";
+    Files.writeString(tokenFile, nonJwtToken);
+
+    // Create file token provider with JWT expiration refresh enabled
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMillis(100),
+            true,
+            Duration.ofSeconds(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test initial token
+      String token1 = provider.getToken();
+      assertThat(token1).isEqualTo(nonJwtToken);
+
+      // Advance time past fallback refresh interval
+      monotonicClock.advanceBoth(Duration.ofMillis(150));
+
+      // Update the file
+      String updatedToken = "updated-non-jwt-token";
+      Files.writeString(tokenFile, updatedToken);
+
+      assertThat(provider.getToken()).isEqualTo(token1);
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Test that token is refreshed using fallback interval
+      String token2 = provider.getToken();
+      assertThat(token2).isEqualTo(updatedToken);
+    }
+  }
+
+  @Test
+  public void testJwtExpirationTooSoon() throws IOException {
+    // Create a temporary token file with a JWT that expires very soon (in the past)
+    Path tokenFile = tempDir.resolve("jwt-token.txt");
+    String expiredJwtToken = createJwtWithExpiration(Instant.now().minusSeconds(1));
+    Files.writeString(tokenFile, expiredJwtToken);
+
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create file token provider with JWT expiration refresh enabled
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMinutes(5),
+            true,
+            Duration.ofSeconds(60),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Should fall back to fixed interval when JWT expires too soon
+      String token = provider.getToken();
+      assertThat(token).isEqualTo(expiredJwtToken);
+    }
+  }
+
+  @Test
+  public void testJwtWithoutExpirationClaim() throws IOException {
+    // Create a temporary token file with a JWT without expiration
+    Path tokenFile = tempDir.resolve("jwt-token.txt");
+    String jwtWithoutExp = createJwtWithoutExpiration();
+    Files.writeString(tokenFile, jwtWithoutExp);
+
+    MutableMonotonicClock monotonicClock = new MutableMonotonicClock();
+    MockAsyncExec asyncExec = new MockAsyncExec(monotonicClock);
+
+    // Create file token provider with JWT expiration refresh enabled
+    try (FileBearerTokenProvider provider =
+        new FileBearerTokenProvider(
+            tokenFile,
+            Duration.ofMillis(100),
+            true,
+            Duration.ofSeconds(1),
+            Duration.ofMillis(1),
+            asyncExec,
+            monotonicClock::currentInstant)) {
+      // run outstanding token-refresh task
+      asyncExec.readyCallables().forEach(MockAsyncExec.Task::call);
+
+      // Should fall back to fixed interval when JWT has no expiration
+      String token = provider.getToken();
+      assertThat(token).isEqualTo(jwtWithoutExp);
+    }
+  }
+
+  /** Helper method to create a JWT with a specific expiration time. */
+  private String createJwtWithExpiration(Instant expiration) {
+    try {
+      ObjectMapper mapper = new ObjectMapper();
+
+      // Create header
+      Map<String, Object> header = new HashMap<>();
+      header.put("alg", "HS256");
+      header.put("typ", "JWT");
+      String headerJson = mapper.writeValueAsString(header);
+      String encodedHeader =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
+
+      // Create payload with expiration
+      Map<String, Object> payload = new HashMap<>();
+      payload.put("iss", "test");
+      payload.put("exp", expiration.getEpochSecond());
+      String payloadJson = mapper.writeValueAsString(payload);
+      String encodedPayload =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
+
+      // Create fake signature (we don't verify signatures)
+      String signature =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8));
+
+      return encodedHeader + "." + encodedPayload + "." + signature;
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to create test JWT", e);
+    }
+  }
+
+  /** Helper method to create a JWT without an expiration claim. */
+  private String createJwtWithoutExpiration() {
+    try {
+      ObjectMapper mapper = new ObjectMapper();
+
+      // Create header
+      Map<String, Object> header = new HashMap<>();
+      header.put("alg", "HS256");
+      header.put("typ", "JWT");
+      String headerJson = mapper.writeValueAsString(header);
+      String encodedHeader =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
+
+      // Create payload without expiration
+      Map<String, Object> payload = new HashMap<>();
+      payload.put("iss", "test");
+      payload.put("custom", "value");
+      String payloadJson = mapper.writeValueAsString(payload);
+      String encodedPayload =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
+
+      // Create fake signature (we don't verify signatures)
+      String signature =
+          Base64.getUrlEncoder()
+              .withoutPadding()
+              .encodeToString("fake-signature".getBytes(StandardCharsets.UTF_8));
+
+      return encodedHeader + "." + encodedPayload + "." + signature;
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to create test JWT", e);
+    }
+  }
+}
diff --git a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java
new file mode 100644
index 000000000..7886d8fee
--- /dev/null
+++ b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/token/StaticBearerTokenProviderTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.token;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+public class StaticBearerTokenProviderTest {
+
+  @Test
+  public void testStaticBearerTokenProvider() {
+    String expectedToken = "static-bearer-token";
+    try (StaticBearerTokenProvider provider = new StaticBearerTokenProvider(expectedToken)) {
+      String actualToken = provider.getToken();
+      assertThat(actualToken).isEqualTo(expectedToken);
+    }
+  }
+
+  @Test
+  public void testStaticBearerTokenProviderWithEmptyString() {
+    // Empty strings should be rejected
+    assertThatThrownBy(() -> new StaticBearerTokenProvider(""))
+        .isInstanceOf(IllegalArgumentException.class);
+  }
+
+  @Test
+  public void testStaticBearerTokenProviderWithNullString() {
+    // Null tokens should be rejected
+    assertThatThrownBy(() -> new StaticBearerTokenProvider(null))
+        .isInstanceOf(IllegalArgumentException.class);
+  }
+}
diff --git a/extensions/auth/opa/tests/build.gradle.kts b/extensions/auth/opa/tests/build.gradle.kts
new file mode 100644
index 000000000..bab1a4421
--- /dev/null
+++ b/extensions/auth/opa/tests/build.gradle.kts
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+  alias(libs.plugins.quarkus)
+  id("org.kordamp.gradle.jandex")
+  id("polaris-runtime")
+}
+
+dependencies {
+  // Quarkus platform
+  implementation(platform(libs.quarkus.bom))
+  implementation("io.quarkus:quarkus-rest-jackson")
+
+  // Add the OPA implementation as RUNTIME dependency to include in Quarkus app
+  implementation(project(":polaris-extensions-auth-opa"))
+
+  // Include all runtime-service dependencies
+  implementation(project(":polaris-runtime-service"))
+
+  // Test common for integration testing
+  testImplementation(project(":polaris-runtime-test-common"))
+
+  // Test dependencies
+  intTestImplementation("io.quarkus:quarkus-junit5")
+  intTestImplementation("io.rest-assured:rest-assured")
+
+  // Test container dependencies
+  intTestImplementation(platform(libs.testcontainers.bom))
+  intTestImplementation("org.testcontainers:junit-jupiter")
+  intTestImplementation(project(":polaris-container-spec-helper"))
+}
+
+tasks.named("javadoc") { dependsOn("jandex") }
+
+tasks.withType<Test> {
+  if (System.getenv("AWS_REGION") == null) {
+    environment("AWS_REGION", "us-west-2")
+  }
+  environment("POLARIS_BOOTSTRAP_CREDENTIALS", "POLARIS,test-admin,test-secret")
+  jvmArgs("--add-exports", "java.base/sun.nio.ch=ALL-UNNAMED")
+  systemProperty("java.security.manager", "allow")
+  maxParallelForks = 1
+
+  val logsDir = project.layout.buildDirectory.get().asFile.resolve("logs")
+
+  jvmArgumentProviders.add(
+    CommandLineArgumentProvider {
+      listOf("-Dquarkus.log.file.path=${logsDir.resolve("polaris.log").absolutePath}")
+    }
+  )
+
+  doFirst {
+    logsDir.deleteRecursively()
+    project.layout.buildDirectory.get().asFile.resolve("quarkus.log").delete()
+  }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java
new file mode 100644
index 000000000..da4ba9272
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaFileTokenIntegrationTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry;
+import io.quarkus.test.junit.TestProfile;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for OPA with file-based bearer token authentication.
+ *
+ * <p>These tests verify that OpaPolarisAuthorizer correctly reads bearer tokens from a file and
+ * uses them to authenticate with OPA.
+ */
+@QuarkusTest
+@TestProfile(OpaFileTokenIntegrationTest.FileTokenOpaProfile.class)
+public class OpaFileTokenIntegrationTest extends OpaIntegrationTestBase {
+
+  /**
+   * Test profile for OPA integration with file-based bearer token authentication. The OPA container
+   * runs with HTTP for simplicity in CI environments.
+   */
+  public static class FileTokenOpaProfile implements QuarkusTestProfile {
+    // Static field to hold token file path for test access
+    public static Path tokenFilePath;
+
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      try {
+        // Create token file early so SmallRye Config validation sees the property
+        tokenFilePath = Files.createTempFile("opa-test-token", ".txt");
+        Files.writeString(tokenFilePath, "test-opa-bearer-token-from-file");
+
+        Map<String, String> config = new HashMap<>();
+        config.put("polaris.authorization.type", "opa");
+
+        // Configure file-based bearer token authentication
+        config.put("polaris.authorization.opa.auth.type", "bearer");
+        config.put(
+            "polaris.authorization.opa.auth.bearer.file-based.path", tokenFilePath.toString());
+        config.put("polaris.authorization.opa.auth.bearer.file-based.refresh-interval", "PT1S");
+
+        return config;
+      } catch (IOException e) {
+        throw new RuntimeException("Failed to create test token file", e);
+      }
+    }
+
+    @Override
+    public List<TestResourceEntry> testResources() {
+      return List.of(new TestResourceEntry(OpaTestResource.class));
+    }
+  }
+
+  @Test
+  void testOpaAllowsRootUser() {
+    String rootToken = getRootToken();
+
+    // Use the Bearer token to test OPA authorization
+    // The JWT token has principal "root" which our policy allows
+    given()
+        .header("Authorization", "Bearer " + rootToken)
+        .when()
+        .get("api/management/v1/catalogs")
+        .then()
+        .statusCode(200); // Should succeed - "root" user is allowed by policy
+  }
+
+  @Test
+  void testCreatePrincipalAndGetToken() {
+    // Test the helper method createPrincipalAndGetToken
+    // useful for debugging and ensuring that the helper method works correctly
+    assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user"));
+  }
+
+  @Test
+  void testOpaPolicyDeniesStrangerUser() {
+    // Create a "stranger" principal and get its access token
+    String strangerToken = createPrincipalAndGetToken("stranger");
+
+    // Use the stranger token to test OPA authorization - should be denied
+    given()
+        .header("Authorization", "Bearer " + strangerToken)
+        .when()
+        .get("/api/management/v1/catalogs")
+        .then()
+        .statusCode(403); // Should be forbidden by OPA policy - stranger is denied
+  }
+
+  @Test
+  void testOpaAllowsAdminUser() {
+    // Create an "admin" principal and get its access token
+    String adminToken = createPrincipalAndGetToken("admin");
+
+    // Use the admin token to test OPA authorization - should be allowed
+    given()
+        .header("Authorization", "Bearer " + adminToken)
+        .when()
+        .get("/api/management/v1/catalogs")
+        .then()
+        .statusCode(200); // Should succeed - admin user is allowed by policy
+  }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java
new file mode 100644
index 000000000..2156f696e
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.QuarkusTestProfile.TestResourceEntry;
+import io.quarkus.test.junit.TestProfile;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+@TestProfile(OpaIntegrationTest.StaticTokenOpaProfile.class)
+public class OpaIntegrationTest extends OpaIntegrationTestBase {
+
+  /**
+   * Test demonstrates OPA integration with bearer token authentication. The OPA container runs with
+   * HTTP for simplicity in CI environments. The OpaPolarisAuthorizer is configured to disable SSL
+   * verification for test purposes.
+   */
+  public static class StaticTokenOpaProfile implements QuarkusTestProfile {
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      Map<String, String> config = new HashMap<>();
+      config.put("polaris.authorization.type", "opa");
+
+      // Configure static token authentication
+      config.put("polaris.authorization.opa.auth.type", "bearer");
+      config.put(
+          "polaris.authorization.opa.auth.bearer.static-token.value",
+          "test-opa-bearer-token-12345");
+
+      return config;
+    }
+
+    @Override
+    public List<TestResourceEntry> testResources() {
+      return List.of(new TestResourceEntry(OpaTestResource.class));
+    }
+  }
+
+  @Test
+  void testOpaAllowsRootUser() {
+    String rootToken = getRootToken();
+
+    // Use the Bearer token to test OPA authorization
+    // The JWT token has principal "root" which our policy allows
+    given()
+        .header("Authorization", "Bearer " + rootToken)
+        .when()
+        .get("api/management/v1/catalogs")
+        .then()
+        .statusCode(200); // Should succeed - "root" user is allowed by policy
+  }
+
+  @Test
+  void testCreatePrincipalAndGetToken() {
+    // Test the helper method createPrincipalAndGetToken
+    // useful for debugging and ensuring that the helper method works correctly
+    assertThatNoException().isThrownBy(() -> createPrincipalAndGetToken("test-user"));
+  }
+
+  @Test
+  void testOpaPolicyDeniesStrangerUser() {
+    // Create a "stranger" principal and get its access token
+    String strangerToken = createPrincipalAndGetToken("stranger");
+
+    // Use the stranger token to test OPA authorization - should be denied
+    given()
+        .header("Authorization", "Bearer " + strangerToken)
+        .when()
+        .get("/api/management/v1/catalogs")
+        .then()
+        .statusCode(403); // Should be forbidden by OPA policy - stranger is denied
+  }
+
+  @Test
+  void testOpaAllowsAdminUser() {
+    // Create an "admin" principal and get its access token
+    String adminToken = createPrincipalAndGetToken("admin");
+
+    // Use the admin token to test OPA authorization - should be allowed
+    given()
+        .header("Authorization", "Bearer " + adminToken)
+        .when()
+        .get("/api/management/v1/catalogs")
+        .then()
+        .statusCode(200); // Should succeed - admin user is allowed by policy
+  }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java
new file mode 100644
index 000000000..9bbd326d9
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaIntegrationTestBase.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+import static io.restassured.RestAssured.given;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Base class for OPA integration tests providing common helper methods for authentication and
+ * principal management.
+ */
+public abstract class OpaIntegrationTestBase {
+
+  /**
+   * Helper method to get root access token using the default test admin credentials.
+   *
+   * @return the access token for the root user
+   */
+  protected String getRootToken() {
+    String response =
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("grant_type", "client_credentials")
+            .formParam("client_id", "test-admin")
+            .formParam("client_secret", "test-secret")
+            .formParam("scope", "PRINCIPAL_ROLE:ALL")
+            .when()
+            .post("/api/catalog/v1/oauth/tokens")
+            .then()
+            .statusCode(200)
+            .extract()
+            .body()
+            .asString();
+
+    String accessToken = extractJsonValue(response, "access_token");
+    if (accessToken == null) {
+      fail("Failed to parse access_token from admin OAuth response: " + response);
+    }
+    return accessToken;
+  }
+
+  /**
+   * Helper method to create a principal and get an OAuth access token for that principal.
+   *
+   * @param principalName the name of the principal to create
+   * @return the access token for the newly created principal
+   */
+  protected String createPrincipalAndGetToken(String principalName) {
+    // First get root token to create the principal
+    String rootToken = getRootToken();
+
+    // Create the principal using the root token
+    String createResponse =
+        given()
+            .contentType("application/json")
+            .header("Authorization", "Bearer " + rootToken)
+            .body("{\"principal\":{\"name\":\"" + principalName + "\",\"properties\":{}}}")
+            .when()
+            .post("/api/management/v1/principals")
+            .then()
+            .statusCode(201)
+            .extract()
+            .body()
+            .asString();
+
+    // Parse the principal's credentials from the response
+    String clientId = extractJsonValue(createResponse, "clientId");
+    String clientSecret = extractJsonValue(createResponse, "clientSecret");
+
+    if (clientId == null || clientSecret == null) {
+      fail("Could not parse principal credentials from response: " + createResponse);
+    }
+
+    // Get access token for the newly created principal
+    String tokenResponse =
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("grant_type", "client_credentials")
+            .formParam("client_id", clientId)
+            .formParam("client_secret", clientSecret)
+            .formParam("scope", "PRINCIPAL_ROLE:ALL")
+            .when()
+            .post("/api/catalog/v1/oauth/tokens")
+            .then()
+            .statusCode(200)
+            .extract()
+            .body()
+            .asString();
+
+    String accessToken = extractJsonValue(tokenResponse, "access_token");
+    if (accessToken == null) {
+      fail("Could not get access token for principal " + principalName);
+    }
+
+    return accessToken;
+  }
+
+  /**
+   * Simple JSON value extractor for parsing values from JSON responses.
+   *
+   * @param json the JSON string to parse
+   * @param key the key to extract the value for
+   * @return the extracted value, or null if not found
+   */
+  protected String extractJsonValue(String json, String key) {
+    String searchKey = "\"" + key + "\"";
+    if (json.contains(searchKey)) {
+      String value = json.substring(json.indexOf(searchKey) + searchKey.length());
+      value = value.substring(value.indexOf("\"") + 1);
+      value = value.substring(0, value.indexOf("\""));
+      return value;
+    }
+    return null;
+  }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java
new file mode 100644
index 000000000..0bc0385b9
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/java/org/apache/polaris/extension/auth/opa/test/OpaTestResource.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.test;
+
+import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.containerspec.ContainerSpecHelper;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+public class OpaTestResource implements QuarkusTestResourceLifecycleManager {
+  private static GenericContainer<?> opa;
+  private int mappedPort;
+
+  @Override
+  public Map<String, String> start() {
+    try {
+      // Reuse container across tests to speed up execution
+      if (opa == null || !opa.isRunning()) {
+        opa =
+            new GenericContainer<>(
+                    ContainerSpecHelper.containerSpecHelper("opa", OpaTestResource.class)
+                        .dockerImageName(null))
+                .withExposedPorts(8181)
+                .withReuse(true)
+                .withCommand("run", "--server", "--addr=0.0.0.0:8181")
+                .waitingFor(
+                    Wait.forHttp("/health")
+                        .forPort(8181)
+                        .forStatusCode(200)
+                        .withStartupTimeout(Duration.ofSeconds(120)));
+
+        opa.start();
+      }
+
+      mappedPort = opa.getMappedPort(8181);
+      String containerHost = opa.getHost();
+      String baseUrl = "http://" + containerHost + ":" + mappedPort;
+
+      // Load Opa Polaris Authorizer Rego policy into OPA
+      String polarisPolicyName = "polaris-authz";
+      String polarisRegoPolicy =
+          """
+        package polaris.authz
+
+        default allow := false
+
+        # Allow root user for all operations
+        allow {
+          input.actor.principal == "root"
+        }
+
+        # Allow admin user for all operations
+        allow {
+          input.actor.principal == "admin"
+        }
+        """;
+      loadRegoPolicy(baseUrl, polarisPolicyName, polarisRegoPolicy);
+
+      Map<String, String> config = new HashMap<>();
+      config.put("polaris.authorization.opa.policy-uri", baseUrl + "/v1/data/polaris/authz");
+
+      return config;
+
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to start OPA test resource", e);
+    }
+  }
+
+  private void loadRegoPolicy(String baseUrl, String policyName, String regoPolicy) {
+    // Hardcode the policy directly instead of loading through QuarkusTestProfile
+    try {
+      URL url = new URL(baseUrl + "/v1/policies/" + policyName);
+      System.out.println("Uploading policy to: " + url);
+
+      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+      conn.setRequestMethod("PUT");
+      conn.setDoOutput(true);
+      conn.setRequestProperty("Content-Type", "text/plain");
+
+      try (OutputStream os = conn.getOutputStream()) {
+        os.write(regoPolicy.getBytes(StandardCharsets.UTF_8));
+      }
+
+      int code = conn.getResponseCode();
+      System.out.println("OPA policy upload response code: " + code);
+
+      if (code < 200 || code >= 300) {
+        throw new RuntimeException("OPA policy upload failed, HTTP " + code);
+      }
+
+      System.out.println("Successfully uploaded policy to OPA");
+    } catch (Exception e) {
+      // Surface container logs to help debug on CI
+      String logs = "";
+      try {
+        logs = opa.getLogs();
+      } catch (Throwable ignored) {
+      }
+      throw new RuntimeException("Failed to load OPA policy. Container logs:\n" + logs, e);
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Don't stop the container to allow reuse across tests
+    // Container will be cleaned up when the JVM exits
+  }
+}
diff --git a/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version
new file mode 100644
index 000000000..701452b6c
--- /dev/null
+++ b/extensions/auth/opa/tests/src/intTest/resources/org/apache/polaris/extension/auth/opa/test/Dockerfile-opa-version
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+FROM docker.io/openpolicyagent/opa:0.63.0
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b3aa4b6db..17682d9da 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -41,6 +41,7 @@ swagger = "1.6.16"
 #
 quarkus-amazon-services-bom = { module = "io.quarkus.platform:quarkus-amazon-services-bom", version.ref="quarkus" }
 antlr4-runtime = { module = "org.antlr:antlr4-runtime", version.strictly = "4.9.3" } # spark integration tests
+apache-httpclient5 = { module = "org.apache.httpcomponents.client5:httpclient5", version = "5.5.1" }
 assertj-core = { module = "org.assertj:assertj-core", version = "3.27.6" }
 auth0-jwt = { module = "com.auth0:java-jwt", version = "4.5.0" }
 awssdk-bom = { module = "software.amazon.awssdk:bom", version = "2.36.2" }
diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties
index c77a06fd7..5869bc5bc 100644
--- a/gradle/projects.main.properties
+++ b/gradle/projects.main.properties
@@ -43,6 +43,8 @@ polaris-version=tools/version
 polaris-misc-types=tools/misc-types
 polaris-extensions-federation-hadoop=extensions/federation/hadoop
 polaris-extensions-federation-hive=extensions/federation/hive
+polaris-extensions-auth-opa=extensions/auth/opa/impl
+polaris-extensions-auth-opa-tests=extensions/auth/opa/tests
 
 polaris-config-docs-annotations=tools/config-docs/annotations
 polaris-config-docs-generator=tools/config-docs/generator
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java
new file mode 100644
index 000000000..a50b37223
--- /dev/null
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/DefaultPolarisAuthorizerFactory.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.core.auth;
+
+import org.apache.polaris.core.config.RealmConfig;
+
+/** Factory for creating the default Polaris authorizer implementation. */
+public class DefaultPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
+
+  @Override
+  public PolarisAuthorizer create(RealmConfig realmConfig) {
+    return new PolarisAuthorizerImpl(realmConfig);
+  }
+}
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java
new file mode 100644
index 000000000..7d01934ec
--- /dev/null
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerFactory.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.core.auth;
+
+import org.apache.polaris.core.config.RealmConfig;
+
+/**
+ * Factory interface for creating PolarisAuthorizer instances.
+ *
+ * <p>This follows the standard Polaris pattern of using CDI with @Identifier annotations to select
+ * different implementations at runtime.
+ */
+public interface PolarisAuthorizerFactory {
+
+  /**
+   * Creates a PolarisAuthorizer instance with the given realm configuration.
+   *
+   * @param realmConfig the realm configuration
+   * @return a configured PolarisAuthorizer instance
+   */
+  PolarisAuthorizer create(RealmConfig realmConfig);
+}
diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java
index 9d943823a..e98f0302a 100644
--- a/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java
+++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/PolarisAuthorizerImpl.java
@@ -127,7 +127,6 @@ import com.google.common.collect.HashMultimap;
 import com.google.common.collect.SetMultimap;
 import jakarta.annotation.Nonnull;
 import jakarta.annotation.Nullable;
-import jakarta.inject.Inject;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -728,7 +727,6 @@ public class PolarisAuthorizerImpl implements PolarisAuthorizer {
 
   private final RealmConfig realmConfig;
 
-  @Inject
   public PolarisAuthorizerImpl(RealmConfig realmConfig) {
     this.realmConfig = realmConfig;
   }
diff --git a/runtime/defaults/src/main/resources/application.properties b/runtime/defaults/src/main/resources/application.properties
index 0358eb4b5..54c008273 100644
--- a/runtime/defaults/src/main/resources/application.properties
+++ b/runtime/defaults/src/main/resources/application.properties
@@ -206,6 +206,40 @@ polaris.oidc.principal-roles-mapper.type=default
 # polaris.storage.gcp.token=token
 # polaris.storage.gcp.lifespan=PT1H
 
+# Polaris authorization type settings
+# Which authorizer to use: "internal" (PolarisAuthorizerImpl) or "opa" (OpaPolarisAuthorizer)
+# polaris.authorization.type=internal
+
+# OPA Authorizer Configuration: effective only if polaris.authorization.type=opa
+# NOTE: The OPA Authorizer is currently in Beta and is not a stable release.
+# It may undergo breaking changes in future versions.
+# polaris.authorization.opa.url=http://localhost:8181
+# polaris.authorization.opa.policy-path=/v1/data/polaris/authz/allow
+
+# OPA HTTP configuration
+# polaris.authorization.opa.http.timeout=PT2S
+# NOTE: Setting verify-ssl=false will trigger a severe production readiness check error
+# as this exposes the service to security risks.
+# polaris.authorization.opa.http.verify-ssl=false
+# polaris.authorization.opa.http.trust-store-path=/path/to/truststore
+# polaris.authorization.opa.http.trust-store-password=my-trust-store-password
+
+# OPA Authentication configuration
+# Default is no authentication (type=none)
+# To enable bearer token authentication, use type=bearer
+# polaris.authorization.opa.auth.type=none
+# To enable bearer token authentication, uncomment the following:
+# polaris.authorization.opa.auth.type=bearer
+
+# Static bearer token configuration:
+# polaris.authorization.opa.auth.bearer.static-token.value=my-static-token
+
+# Alternative file-based bearer token configuration:
+# polaris.authorization.opa.auth.bearer.file-based.path=/path/to/token/file
+# polaris.authorization.opa.auth.bearer.file-based.refresh-interval=PT5M
+# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-refresh=true
+# polaris.authorization.opa.auth.bearer.file-based.jwt-expiration-buffer=PT1M
+
 # Polaris Credential Manager Config
 polaris.credential-manager.type=default
 
diff --git a/runtime/server/build.gradle.kts b/runtime/server/build.gradle.kts
index 34f75dd14..eba921a5d 100644
--- a/runtime/server/build.gradle.kts
+++ b/runtime/server/build.gradle.kts
@@ -45,6 +45,7 @@ dependencies {
   runtimeOnly(project(":polaris-relational-jdbc"))
   runtimeOnly("io.quarkus:quarkus-jdbc-postgresql")
   runtimeOnly(project(":polaris-extensions-federation-hadoop"))
+  runtimeOnly(project(":polaris-extensions-auth-opa"))
 
   if ((project.findProperty("NonRESTCatalogs") as String?)?.contains("HIVE") == true) {
     runtimeOnly(project(":polaris-extensions-federation-hive"))
diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts
index 7b41c3d10..af86c8377 100644
--- a/runtime/service/build.gradle.kts
+++ b/runtime/service/build.gradle.kts
@@ -106,6 +106,8 @@ dependencies {
 
   implementation(libs.jakarta.servlet.api)
 
+  runtimeOnly(project(":polaris-async-vertx"))
+
   testFixturesApi(project(":polaris-tests")) {
     // exclude all spark dependencies
     exclude(group = "org.apache.iceberg", module = "iceberg-spark-3.5_2.12")
diff --git a/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java
new file mode 100644
index 000000000..e65c60bcd
--- /dev/null
+++ b/runtime/service/src/intTest/java/org/apache/polaris/service/it/ServiceProducersIT.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.service.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import jakarta.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.junit.jupiter.api.Test;
+
+public class ServiceProducersIT {
+
+  public static class InternalAuthorizationConfig implements QuarkusTestProfile {
+    @Override
+    public Map<String, String> getConfigOverrides() {
+      Map<String, String> config = new HashMap<>();
+      config.put("polaris.authorization.type", "internal");
+      return config;
+    }
+  }
+
+  @QuarkusTest
+  @TestProfile(ServiceProducersIT.InternalAuthorizationConfig.class)
+  public static class InternalAuthorizationTest {
+
+    @Inject PolarisAuthorizer polarisAuthorizer;
+
+    @Test
+    void testInternalPolarisAuthorizerProduced() {
+      assertThat(polarisAuthorizer).isNotNull();
+    }
+  }
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java
new file mode 100644
index 000000000..438dee1ad
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/config/AuthorizationConfiguration.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.service.config;
+
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+
+@ConfigMapping(prefix = "polaris.authorization")
+public interface AuthorizationConfiguration {
+
+  @WithDefault("internal")
+  String type();
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
index 6e25dbfc1..6b5181929 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/config/ServiceProducers.java
@@ -37,8 +37,9 @@ import java.util.stream.Collectors;
 import org.apache.polaris.core.PolarisCallContext;
 import org.apache.polaris.core.PolarisDefaultDiagServiceImpl;
 import org.apache.polaris.core.PolarisDiagnostics;
+import org.apache.polaris.core.auth.DefaultPolarisAuthorizerFactory;
 import org.apache.polaris.core.auth.PolarisAuthorizer;
-import org.apache.polaris.core.auth.PolarisAuthorizerImpl;
+import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
 import org.apache.polaris.core.config.PolarisConfigurationStore;
 import org.apache.polaris.core.config.RealmConfig;
 import org.apache.polaris.core.context.CallContext;
@@ -139,10 +140,28 @@ public class ServiceProducers {
     return callContext.getRealmConfig();
   }
 
+  @Produces
+  @ApplicationScoped
+  @Identifier("internal")
+  public PolarisAuthorizerFactory defaultPolarisAuthorizerFactory() {
+    return new DefaultPolarisAuthorizerFactory();
+  }
+
+  @Produces
+  @ApplicationScoped
+  public PolarisAuthorizerFactory polarisAuthorizerFactory(
+      AuthorizationConfiguration authorizationConfig,
+      @Any Instance<PolarisAuthorizerFactory> authorizerFactories) {
+    PolarisAuthorizerFactory factory =
+        authorizerFactories.select(Identifier.Literal.of(authorizationConfig.type())).get();
+    return factory;
+  }
+
   @Produces
   @RequestScoped
-  public PolarisAuthorizer polarisAuthorizer(RealmConfig realmConfig) {
-    return new PolarisAuthorizerImpl(realmConfig);
+  public PolarisAuthorizer polarisAuthorizer(
+      PolarisAuthorizerFactory factory, RealmConfig realmConfig) {
+    return factory.create(realmConfig);
   }
 
   @Produces