Java SDK for Frontegg's entitlements engine. Perform feature, permission, and fine-grained authorization (FGA) checks from JVM applications against Frontegg's SpiceDB-backed ReBAC engine over gRPC.
⚠️ Pre-1.0 / Beta This SDK is in active development. Until version 1.0.0 is released, breaking changes may occur between minor versions. Pin to an exact version in production and review the release notes before upgrading.
The client is thread-safe, manages a single multiplexed HTTP/2 gRPC channel, and is designed to be created once and shared for the lifetime of an application.
- Requirements
- Modules
- Installation
- Quick Start
- Usage
- Spring Boot Starter
- Test Utilities
- Compatibility
- Contributing
- License
- Java 17 or later (sealed interfaces and records are used throughout the public API)
- Compatible with Spring Boot 3.2+, Quarkus 3.x, and Micronaut 4.x
- An SLF4J-compatible logging implementation on the runtime classpath (e.g. Logback, Log4j2)
The SDK is published as four separate artifacts. Use the BOM to align versions across them.
| Module | Artifact ID | Description |
|---|---|---|
| Core | entitlements-client |
Main SDK — gRPC client, check API, fallback, caching |
| BOM | entitlements-client-bom |
Bill of Materials for aligning dependency versions |
| Spring Boot Starter | entitlements-client-spring-boot-starter |
Auto-configures EntitlementsClient from application.properties |
| Test Utilities | entitlements-client-test |
MockEntitlementsClient and RecordingEntitlementsClient |
<dependency>
<groupId>com.frontegg.sdk</groupId>
<artifactId>entitlements-client</artifactId>
<version>0.1.0</version>
</dependency>implementation 'com.frontegg.sdk:entitlements-client:0.1.0'Or with the Kotlin DSL:
implementation("com.frontegg.sdk:entitlements-client:0.1.0")If you use the Frontegg BOM to align dependency versions across multiple Frontegg SDK artifacts,
import it in your dependencyManagement block and then declare the dependency without a version:
Maven:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.frontegg.sdk</groupId>
<artifactId>entitlements-client-bom</artifactId>
<version>0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.frontegg.sdk</groupId>
<artifactId>entitlements-client</artifactId>
</dependency>
</dependencies>Gradle:
implementation(platform("com.frontegg.sdk:entitlements-client-bom:0.1.0"))
implementation("com.frontegg.sdk:entitlements-client")Spring Boot applications can use the starter module instead of the core artifact. The starter
provides auto-configuration driven entirely by application.properties — no Java configuration
class is required. See the Spring Boot Starter section for details.
The following example shows the minimal steps needed to check whether a user is entitled to a feature:
import com.frontegg.sdk.entitlements.EntitlementsClient;
import com.frontegg.sdk.entitlements.EntitlementsClientFactory;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.PermissionRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
// 1. Build the configuration. engineEndpoint and engineToken are required.
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.build();
// 2. Create the client. Validate at startup — the factory throws immediately on
// misconfiguration rather than waiting for the first query.
try (EntitlementsClient client = EntitlementsClientFactory.create(config)) {
// 3. Check a feature flag for a specific user in a tenant.
EntitlementsResult featureResult = client.isEntitledTo(
new UserSubjectContext("user-123", "tenant-456"),
new FeatureRequestContext("advanced-reporting")
);
if (featureResult.result()) {
System.out.println("Access granted");
} else {
System.out.println("Access denied");
}
// 4. Check a permission for the same user.
EntitlementsResult permissionResult = client.isEntitledTo(
new UserSubjectContext("user-123", "tenant-456"),
new PermissionRequestContext("reports:export")
);
if (!permissionResult.result()) {
throw new AccessDeniedException("Insufficient permissions");
}
}
// 5. The try-with-resources block closes the client, releasing the gRPC channel.In a long-running application (Spring Boot, Quarkus, Micronaut) declare the client as a singleton bean rather than using try-with-resources. Call
client.close()in the bean destroy lifecycle method.
ClientConfiguration is an immutable value object built with a fluent builder. All required
fields are validated in build().
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import com.frontegg.sdk.entitlements.config.CacheConfiguration;
import com.frontegg.sdk.entitlements.config.ConsistencyPolicy;
import com.frontegg.sdk.entitlements.fallback.StaticFallback;
import java.time.Duration;
ClientConfiguration config = ClientConfiguration.builder()
// Required: the SpiceDB gRPC endpoint (host:port)
.engineEndpoint("grpc.authz.example.com:443")
// Required: bearer token for authenticating gRPC calls.
// Pass a static String for simplicity, or a Supplier<String> for rotation.
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
// Optional: deny all access if the engine is unreachable (fail-closed).
.fallbackStrategy(new StaticFallback(false))
// Optional: per-request deadline. Default: 5 seconds.
.requestTimeout(Duration.ofSeconds(3))
// Optional: deadline for bulk checks. Default: 15 seconds.
.bulkRequestTimeout(Duration.ofSeconds(10))
// Optional: maximum retry attempts for transient gRPC failures. Default: 3.
.maxRetries(3)
// Optional: enable TLS on the gRPC channel. Default: true.
// Set to false only for local development against a plaintext server.
.useTls(true)
// Optional: enable monitoring mode (see Monitoring Mode section). Default: false.
.monitoring(false)
// Optional: SpiceDB read consistency. Default: MINIMIZE_LATENCY.
// Use FULLY_CONSISTENT when read-after-write consistency is required.
.consistencyPolicy(ConsistencyPolicy.MINIMIZE_LATENCY)
// Optional: enable in-memory result caching (see Caching section). Default: disabled.
.cacheConfiguration(CacheConfiguration.defaults())
.build();All configuration options:
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
engineEndpoint |
String |
— | Yes | SpiceDB gRPC endpoint in host:port form, e.g. grpc.authz.example.com:443. |
engineToken |
String or Supplier<String> |
— | Yes | Bearer token for gRPC authentication. Pass a Supplier to enable credential rotation without recreating the client. |
fallbackStrategy |
FallbackStrategy |
null |
No | Determines the result when the engine is unreachable after all retries. Exceptions propagate when not set. |
requestTimeout |
Duration |
5 seconds | No | gRPC deadline for a single entitlement check. |
bulkRequestTimeout |
Duration |
15 seconds | No | gRPC deadline for bulk entitlement checks. |
maxRetries |
int |
3 |
No | Maximum retry attempts with exponential backoff before the fallback is invoked or the exception propagates. |
useTls |
boolean |
true |
No | Whether to use TLS on the gRPC channel. Disable only for local development. |
monitoring |
boolean |
false |
No | When true, checks are evaluated and logged but always return allowed. See Monitoring Mode. |
consistencyPolicy |
ConsistencyPolicy |
MINIMIZE_LATENCY |
No | SpiceDB read consistency: MINIMIZE_LATENCY (fastest, allows stale reads) or FULLY_CONSISTENT (linearizable). |
cacheConfiguration |
CacheConfiguration |
null |
No | When set, results are cached in memory. null disables caching. See Caching. |
Credential rotation is supported by providing a Supplier<String> for the token. The
supplier is invoked on every gRPC call so that refreshed credentials are picked up without
restarting the application:
// Example: rotate the token from an external secret store
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(() -> secretStore.getSecret("entitlements-engine-token"))
.build();Use FeatureRequestContext to check whether a subject has access to a named feature flag.
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
UserSubjectContext subject = new UserSubjectContext("user-123", "tenant-456");
FeatureRequestContext request = new FeatureRequestContext("advanced-reporting");
EntitlementsResult result = client.isEntitledTo(subject, request);
if (result.result()) {
// Render the advanced reporting UI
}With subject attributes — attributes are evaluated by caveat conditions in the authorization engine (for example, checking a user's subscription plan or role):
import java.util.Map;
UserSubjectContext subject = new UserSubjectContext(
"user-123",
"tenant-456",
Map.of("plan", "enterprise", "region", "us-east-1")
);
EntitlementsResult result = client.isEntitledTo(
subject,
new FeatureRequestContext("data-export")
);Use PermissionRequestContext to check a named permission. PermissionRequestContext accepts a
single permission key string.
Important: Permission checks require the subject's permission list to be supplied via
UserSubjectContext. The SDK performs a client-side short-circuit before reaching SpiceDB: ifUserSubjectContext.permissions()is empty or the requested key does not match any entry in the list, the check is immediately denied without a network call. Pass the user's permission keys (obtained from the Frontegg token or session) in thepermissionsargument. Entries support simple glob wildcards —*matches one or more characters,.is treated as a literal dot.
import com.frontegg.sdk.entitlements.model.PermissionRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import java.util.List;
// Supply the user's permission keys to enable the check.
// These typically come from the decoded Frontegg access token.
UserSubjectContext subject = new UserSubjectContext(
"user-123",
"tenant-456",
List.of("reports:read", "reports:export", "fe.billing.*") // user's permission list
);
// Single permission check
EntitlementsResult readResult = client.isEntitledTo(
subject,
new PermissionRequestContext("reports:read")
);
if (!readResult.result()) {
throw new AccessDeniedException("reports:read is required");
}
// Check a second permission for the same subject
EntitlementsResult exportResult = client.isEntitledTo(
subject,
new PermissionRequestContext("reports:export")
);
if (!exportResult.result()) {
throw new AccessDeniedException("reports:export is required");
}The permissions list also supports glob wildcards, so a user holding "fe.billing.*" will pass
a check for "fe.billing.read" at the client-side stage before any SpiceDB call is made.
Use RouteRequestContext to check whether a subject is permitted to access a specific HTTP
method and path. The authorization engine matches the path against frontegg_route relationships
registered in the SpiceDB schema, including regular-expression patterns.
import com.frontegg.sdk.entitlements.model.RouteRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
UserSubjectContext subject = new UserSubjectContext("user-123", "tenant-456");
EntitlementsResult result = client.isEntitledTo(
subject,
new RouteRequestContext("GET", "/api/v1/reports")
);
if (!result.result()) {
throw new AccessDeniedException("Route access denied");
}RouteRequestContext accepts an HTTP method and a path. A two-argument convenience constructor
checks access at the current time. To evaluate access at a specific point in time pass an
Instant as the third argument — see Time-Based Access.
FGA checks operate on arbitrary entity types rather than Frontegg users. Use
EntitySubjectContext to identify the principal and EntityRequestContext to express the
resource and the relation being checked.
import com.frontegg.sdk.entitlements.model.EntitySubjectContext;
import com.frontegg.sdk.entitlements.model.EntityRequestContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
// Subject: a service account identified by type and ID
EntitySubjectContext subject = new EntitySubjectContext("service_account", "svc-deployer-01");
// Request: does the subject have the "deployer" relation on the environment "prod"?
EntityRequestContext request = new EntityRequestContext("environment", "prod", "deployer");
EntitlementsResult result = client.isEntitledTo(subject, request);
if (!result.result()) {
throw new AccessDeniedException("svc-deployer-01 is not a deployer for prod");
}A more concrete document-sharing example:
// Check whether a user (as an entity) is a viewer of a specific document
EntitySubjectContext subject = new EntitySubjectContext("user", "user-123");
EntityRequestContext request = new EntityRequestContext("document", "doc-789", "viewer");
EntitlementsResult result = client.isEntitledTo(subject, request);Every check has a non-blocking counterpart that returns a CompletableFuture. The future
completes on a gRPC callback thread. Chain blocking operations on a separate executor.
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.exception.EntitlementsQueryException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
Executor appExecutor = Executors.newCachedThreadPool(); // Java 17+
// On Java 21+, prefer: Executors.newVirtualThreadPerTaskExecutor()
CompletableFuture<EntitlementsResult> future = client.isEntitledToAsync(
new UserSubjectContext("user-123", "tenant-456"),
new FeatureRequestContext("advanced-reporting")
);
future
.thenApplyAsync(result -> {
if (result.result()) {
return renderAdvancedReport();
}
return renderBasicReport();
}, appExecutor)
.exceptionally(throwable -> {
// Completed exceptionally when no fallback is configured and the engine fails
logger.error("Entitlement check failed", throwable);
return renderBasicReport();
});For Spring WebFlux or other reactive pipelines, wrap the CompletableFuture with
Mono.fromFuture(...) or Flux.from(...) as needed.
Async variants exist for lookup operations as well: lookupResourcesAsync and
lookupSubjectsAsync both return CompletableFuture<LookupResult>.
Lookup operations answer the inverse of a point-in-time entitlement check: instead of asking
"does subject X have access to resource Y?", they ask "which resources does subject X have access
to?" or "which subjects have access to resource Y?". Both operations map to SpiceDB's
LookupResources and LookupSubjects RPCs respectively.
Find all resources of a given type that a subject has access to.
import com.frontegg.sdk.entitlements.model.LookupResourcesRequest;
import com.frontegg.sdk.entitlements.model.LookupResult;
// Find all frontegg_feature resources on which "user-123" holds the "entitled" permission
LookupResourcesRequest request = new LookupResourcesRequest(
"frontegg_user", // subjectType
"user-123", // subjectId
"entitled", // permission
"frontegg_feature" // resourceType
);
LookupResult result = client.lookupResources(request);
// result.entityIds() is an immutable List<String> of matching resource IDs
for (String featureId : result.entityIds()) {
System.out.println("User has access to feature: " + featureId);
}Find all subjects of a given type that have access to a resource.
import com.frontegg.sdk.entitlements.model.LookupSubjectsRequest;
import com.frontegg.sdk.entitlements.model.LookupResult;
// Find all frontegg_user subjects that hold the "viewer" permission on document "doc-789"
LookupSubjectsRequest request = new LookupSubjectsRequest(
"document", // resourceType
"doc-789", // resourceId
"viewer", // permission
"frontegg_user" // subjectType
);
LookupResult result = client.lookupSubjects(request);
for (String userId : result.entityIds()) {
System.out.println("User can view document: " + userId);
}Result type:
LookupResult is a record with a single field:
| Field | Type | Description |
|---|---|---|
entityIds |
List<String> |
Immutable list of matching entity IDs. May be empty; never null. |
The order of IDs returned is not guaranteed to be stable across calls.
The "at" parameter for time-based access is forwarded to the authorization engine as the
"at" field in the caveat context (ISO-8601 format), enabling entitlement checks at a specific
point in time (past or future). When at is omitted, the check is evaluated at the current time.
This is useful for previewing scheduled access windows, auditing historical decisions, or implementing time-limited entitlements.
FeatureRequestContext and RouteRequestContext support an optional Instant at argument.
PermissionRequestContext does not have a time-based variant — use FeatureRequestContext or
RouteRequestContext for time-gated access control.
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.RouteRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import java.time.Instant;
UserSubjectContext subject = new UserSubjectContext("user-123", "tenant-456");
// Feature check at a fixed point in time
Instant trialExpiry = Instant.parse("2026-06-30T23:59:59Z");
EntitlementsResult featureResult = client.isEntitledTo(
subject,
new FeatureRequestContext("advanced-reporting", trialExpiry)
);
// Route check at a fixed point in time
EntitlementsResult routeResult = client.isEntitledTo(
subject,
new RouteRequestContext("GET", "/api/v1/reports", trialExpiry)
);The single-argument constructors are equivalent to passing null for at:
// These two are equivalent
new FeatureRequestContext("advanced-reporting")
new FeatureRequestContext("advanced-reporting", null)The SDK includes optional in-memory result caching backed by
Caffeine. When enabled, each successful entitlement
result is stored keyed by the (subject, request) pair and returned directly on subsequent
identical calls until the entry expires. Caching is disabled by default.
Pass a CacheConfiguration to the builder:
import com.frontegg.sdk.entitlements.config.CacheConfiguration;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import java.time.Duration;
// Use built-in defaults: 10,000 entries, 60-second write TTL
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.cacheConfiguration(CacheConfiguration.defaults())
.build();To tune the cache size or TTL, construct a CacheConfiguration directly:
// Custom configuration: 5,000 entries, 30-second write TTL
CacheConfiguration cacheConfig = new CacheConfiguration(5_000, Duration.ofSeconds(30));
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.cacheConfiguration(cacheConfig)
.build();Cache defaults:
| Parameter | Default value | Description |
|---|---|---|
maxSize |
10,000 |
Maximum number of cached entries (LRU eviction when exceeded) |
expireAfterWrite |
60 seconds |
Time-to-live measured from the moment the entry was written |
The Caffeine library is declared as an optional dependency in the core SDK artifact. If you
enable caching by setting a CacheConfiguration, you must add the Caffeine dependency explicitly
to your project:
Maven:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.2.3</version>
</dependency>Gradle:
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3'If Caffeine is not on the classpath and caching is configured, the client throws a
NoClassDefFoundError when EntitlementsClientFactory.create(...) is called.
Cached results are never returned when monitoring mode is active and are not stored for fallback results — only real engine responses are cached.
A fallback strategy is invoked after all retry attempts are exhausted due to an engine error or timeout. Without a fallback, exceptions propagate to the caller.
There are two built-in strategies.
StaticFallback always returns the same fixed result regardless of which request failed or what
error occurred. It is the simplest option and is appropriate when a uniform fail-open or
fail-closed policy is acceptable for the whole application.
import com.frontegg.sdk.entitlements.fallback.StaticFallback;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
// Fail-closed: deny all requests when the engine is unavailable (recommended for security)
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.fallbackStrategy(new StaticFallback(false))
.build();// Fail-open: allow all requests when the engine is unavailable
// Use with caution — appropriate only when availability is more important than security
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.fallbackStrategy(new StaticFallback(true))
.build();FunctionFallback delegates the decision to a Function<FallbackContext, EntitlementsResult>.
The FallbackContext exposes the original SubjectContext, RequestContext, and the root-cause
Throwable, enabling context-sensitive logic.
import com.frontegg.sdk.entitlements.fallback.FunctionFallback;
import com.frontegg.sdk.entitlements.fallback.FallbackContext;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.exception.EntitlementsTimeoutException;
FunctionFallback fallback = new FunctionFallback(ctx -> {
// Log every fallback invocation for observability
logger.warn("Entitlement check failed for subject={}, request={}, error={}",
ctx.subjectContext(),
ctx.requestContext(),
ctx.error().getMessage());
// Allow access to a specific low-risk feature even when the engine is down
if (ctx.requestContext() instanceof FeatureRequestContext f
&& "status-page".equals(f.featureKey())) {
return EntitlementsResult.allowed();
}
// On timeout, fail-open for internal service accounts
if (ctx.error() instanceof EntitlementsTimeoutException
&& ctx.subjectContext() instanceof EntitySubjectContext e
&& "service_account".equals(e.entityType())) {
return EntitlementsResult.allowed();
}
// Default: fail-closed
return EntitlementsResult.denied();
});
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.fallbackStrategy(fallback)
.build();Monitoring mode is a deployment safety mechanism that lets teams observe what the authorization engine would decide — without enforcing those decisions. When monitoring mode is enabled:
- The SDK sends the real check to SpiceDB and logs the result.
- The method always returns
EntitlementsResult(result=true, monitoring=true)to the caller regardless of what the engine returned. - The
monitoringflag on the result is set totrue, allowing callers that inspect the flag to distinguish monitoring results from enforcement results.
This is useful when introducing entitlement checks into existing code paths where denying access prematurely would break users. Enable enforcement once the observed results confirm the authorization data is correct.
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.monitoring(true) // observe-only, never deny
.build();
EntitlementsClient client = EntitlementsClientFactory.create(config);
EntitlementsResult result = client.isEntitledTo(
new UserSubjectContext("user-123", "tenant-456"),
new FeatureRequestContext("advanced-reporting")
);
// result.result() → always true in monitoring mode
// result.monitoring() → true, indicating this was a monitoring check
if (result.monitoring()) {
// Safe to log but do not enforce
logger.info("Monitoring check completed, engine said: {}",
result.result() ? "allowed" : "denied");
}Switch to enforcement by setting monitoring(false) or removing the call — false is the
default.
When monitoring mode is active, the in-memory cache (if configured) is bypassed entirely — cached results are never returned and monitoring results are never stored. Every call goes directly to SpiceDB.
All SDK exceptions extend EntitlementsException (an unchecked RuntimeException). The full
hierarchy is:
EntitlementsException (RuntimeException)
├── ConfigurationMissingException — a required configuration field (endpoint or token) is absent
├── ConfigurationInvalidException — a configuration field has an invalid value
└── EntitlementsQueryException — the authorization engine returned an error
└── EntitlementsTimeoutException — the gRPC deadline was exceeded
ConfigurationMissingException and ConfigurationInvalidException are thrown at client
creation time by EntitlementsClientFactory.create(...). They indicate a programming error and
are not expected in production.
EntitlementsQueryException and EntitlementsTimeoutException are thrown (or used to
exceptionally complete the CompletableFuture) at query time when no fallback strategy is
configured.
import com.frontegg.sdk.entitlements.exception.EntitlementsTimeoutException;
import com.frontegg.sdk.entitlements.exception.EntitlementsQueryException;
import com.frontegg.sdk.entitlements.exception.ConfigurationMissingException;
import com.frontegg.sdk.entitlements.exception.ConfigurationInvalidException;
// At startup — catch configuration exceptions once during bean initialization
try {
client = EntitlementsClientFactory.create(config);
} catch (ConfigurationMissingException e) {
throw new IllegalStateException("Missing required entitlements configuration: " + e.getMessage(), e);
} catch (ConfigurationInvalidException e) {
throw new IllegalStateException("Invalid entitlements configuration: " + e.getMessage(), e);
}
// At query time — catch query exceptions per request when no fallback is configured
try {
EntitlementsResult result = client.isEntitledTo(subject, request);
return result.result();
} catch (EntitlementsTimeoutException e) {
// The gRPC deadline was exceeded — consider a conservative default
logger.warn("Entitlement check timed out, defaulting to denied", e);
return false;
} catch (EntitlementsQueryException e) {
// The engine returned an error — inspect e.getCause() for the gRPC status
logger.error("Entitlement check failed", e);
return false;
}When a fallback strategy is configured, EntitlementsQueryException and
EntitlementsTimeoutException are swallowed by the SDK and the fallback result is returned
instead — no try-catch is needed at query sites.
Which gRPC errors are retried: only UNAVAILABLE and DEADLINE_EXCEEDED status codes
trigger the retry+backoff logic. Non-transient errors (PERMISSION_DENIED, UNAUTHENTICATED,
INVALID_ARGUMENT, NOT_FOUND, etc.) are thrown immediately on the first attempt without
sleeping or retrying.
The entitlements-client-spring-boot-starter module auto-configures an EntitlementsClient
bean from application.properties or application.yml. No Java configuration class is required.
Maven:
<dependency>
<groupId>com.frontegg.sdk</groupId>
<artifactId>entitlements-client-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>Gradle:
implementation 'com.frontegg.sdk:entitlements-client-spring-boot-starter:0.1.0'Or with the Kotlin DSL:
implementation("com.frontegg.sdk:entitlements-client-spring-boot-starter:0.1.0")Add the following to application.properties:
# Required
frontegg.entitlements.engine-endpoint=grpc.authz.example.com:443
frontegg.entitlements.engine-token=${ENTITLEMENTS_ENGINE_TOKEN}
# Optional — all values shown are the defaults
frontegg.entitlements.use-tls=true
frontegg.entitlements.request-timeout=5s
frontegg.entitlements.bulk-request-timeout=15s
frontegg.entitlements.max-retries=3
frontegg.entitlements.monitoring=false
frontegg.entitlements.consistency-policy=minimize_latency
# Optional — static fallback result when the engine is unreachable.
# true = fail-open, false = fail-closed. Omit to propagate exceptions instead.
# frontegg.entitlements.fallback-result=false
# Optional — set to false to disable auto-configuration entirely
frontegg.entitlements.enabled=true
# Optional — enable in-memory caching (requires Caffeine on the classpath)
frontegg.entitlements.cache.max-size=10000
frontegg.entitlements.cache.expire-after-write=60sOr equivalently in application.yml:
frontegg:
entitlements:
engine-endpoint: grpc.authz.example.com:443
engine-token: ${ENTITLEMENTS_ENGINE_TOKEN}
use-tls: true
request-timeout: 5s
bulk-request-timeout: 15s
max-retries: 3
monitoring: false
consistency-policy: minimize_latency
fallback-result: false
cache:
max-size: 10000
expire-after-write: 60sThe EntitlementsClient bean is created automatically when all of the following conditions are
satisfied:
entitlements-clientis on the classpath (@ConditionalOnClass).frontegg.entitlements.enabledistrue(default).- No existing
EntitlementsClientbean is present in the application context.
Both frontegg.entitlements.engine-endpoint and frontegg.entitlements.engine-token must also
be set to non-blank values — the auto-configuration throws IllegalStateException at bean
creation time if either is missing.
The third condition means you can override the auto-configured bean entirely by declaring your
own @Bean method that returns an EntitlementsClient. This is the recommended approach when
you need credential rotation via a Supplier<String> for the token, which is not expressible in
properties files:
import com.frontegg.sdk.entitlements.EntitlementsClient;
import com.frontegg.sdk.entitlements.EntitlementsClientFactory;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import com.frontegg.sdk.entitlements.fallback.FunctionFallback;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class EntitlementsConfig {
@Bean
public EntitlementsClient entitlementsClient(SecretStore secretStore) {
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint("grpc.authz.example.com:443")
// Supplier enables token rotation without recreating the client
.engineToken(() -> secretStore.getSecret("entitlements-engine-token"))
// Custom fallback logic (not expressible in application.properties)
.fallbackStrategy(new FunctionFallback(ctx -> {
logger.warn("Entitlement engine unreachable: {}", ctx.error().getMessage());
return EntitlementsResult.denied();
}))
.build();
return EntitlementsClientFactory.create(config);
}
}Once the starter is on the classpath and properties are configured, inject the client as a normal Spring bean:
import com.frontegg.sdk.entitlements.EntitlementsClient;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
private final EntitlementsClient entitlementsClient;
public ReportService(EntitlementsClient entitlementsClient) {
this.entitlementsClient = entitlementsClient;
}
public boolean canAccessAdvancedReporting(String userId, String tenantId) {
EntitlementsResult result = entitlementsClient.isEntitledTo(
new UserSubjectContext(userId, tenantId),
new FeatureRequestContext("advanced-reporting")
);
return result.result();
}
}For Quarkus and Micronaut applications, there is no framework-specific starter module. Create the
client manually as a managed singleton bean and call close() in the destroy lifecycle:
Quarkus (CDI):
import com.frontegg.sdk.entitlements.EntitlementsClient;
import com.frontegg.sdk.entitlements.EntitlementsClientFactory;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
import io.quarkus.runtime.Shutdown;
@ApplicationScoped
public class EntitlementsProducer {
private EntitlementsClient client;
@Produces
@Singleton
public EntitlementsClient entitlementsClient() {
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint(System.getenv("ENTITLEMENTS_ENGINE_ENDPOINT"))
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.build();
client = EntitlementsClientFactory.create(config);
return client;
}
@Shutdown
public void onShutdown() {
if (client != null) client.close();
}
}Micronaut:
import com.frontegg.sdk.entitlements.EntitlementsClient;
import com.frontegg.sdk.entitlements.EntitlementsClientFactory;
import com.frontegg.sdk.entitlements.config.ClientConfiguration;
import io.micronaut.context.annotation.Factory;
import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.runtime.event.ApplicationShutdownEvent;
import jakarta.inject.Singleton;
@Factory
public class EntitlementsFactory {
@Singleton
public EntitlementsClient entitlementsClient() {
ClientConfiguration config = ClientConfiguration.builder()
.engineEndpoint(System.getenv("ENTITLEMENTS_ENGINE_ENDPOINT"))
.engineToken(System.getenv("ENTITLEMENTS_ENGINE_TOKEN"))
.build();
return EntitlementsClientFactory.create(config);
}
}Register a shutdown listener or use @PreDestroy on a wrapper bean to call client.close().
The entitlements-client-test module provides two implementations of EntitlementsClient
designed for use in unit and integration tests. Neither implementation makes any network calls.
Add the dependency in test scope:
Maven:
<dependency>
<groupId>com.frontegg.sdk</groupId>
<artifactId>entitlements-client-test</artifactId>
<version>0.1.0</version>
<scope>test</scope>
</dependency>Gradle:
testImplementation 'com.frontegg.sdk:entitlements-client-test:0.1.0'Or with the Kotlin DSL:
testImplementation("com.frontegg.sdk:entitlements-client-test:0.1.0")MockEntitlementsClient is a configurable stub. All entitlement checks return
EntitlementsResult.denied() by default. Use setDefaultResult to change the default for all
checks, or setHandler for per-request logic.
import com.frontegg.sdk.entitlements.test.MockEntitlementsClient;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.LookupResult;
import com.frontegg.sdk.entitlements.model.LookupResourcesRequest;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
import java.util.List;
// Default: all checks return denied
MockEntitlementsClient mock = new MockEntitlementsClient();
// Allow all checks
mock.setDefaultResult(EntitlementsResult.allowed());
// Custom handler for per-request control
mock.setHandler((subject, request) -> {
if (request instanceof FeatureRequestContext f
&& "premium-feature".equals(f.featureKey())) {
return EntitlementsResult.denied();
}
return EntitlementsResult.allowed();
});
// Configure lookup results
mock.setDefaultLookupResourcesResult(
new LookupResult(List.of("feature-a", "feature-b"))
);
mock.setDefaultLookupSubjectsResult(
new LookupResult(List.of("user-1", "user-2"))
);
// Use in tests
EntitlementsResult result = mock.isEntitledTo(
new UserSubjectContext("user-123", "tenant-456"),
new FeatureRequestContext("advanced-reporting")
);
LookupResult resources = mock.lookupResources(
new LookupResourcesRequest("frontegg_user", "user-123", "entitled", "frontegg_feature")
);MockEntitlementsClient also tracks whether close() has been called:
mock.close();
assertTrue(mock.isClosed());RecordingEntitlementsClient is a decorator that wraps a real or mock client and records every
call made to it. Use it to assert that your application code makes the entitlement checks you
expect.
import com.frontegg.sdk.entitlements.test.MockEntitlementsClient;
import com.frontegg.sdk.entitlements.test.RecordingEntitlementsClient;
import com.frontegg.sdk.entitlements.model.EntitlementsResult;
import com.frontegg.sdk.entitlements.model.FeatureRequestContext;
import com.frontegg.sdk.entitlements.model.UserSubjectContext;
// Wrap any EntitlementsClient implementation with the recorder
MockEntitlementsClient mock = new MockEntitlementsClient();
mock.setDefaultResult(EntitlementsResult.allowed());
RecordingEntitlementsClient recorder = new RecordingEntitlementsClient(mock);
// Run the code under test, passing recorder wherever EntitlementsClient is needed
myService.handleRequest(recorder);
// Assert on recorded isEntitledTo calls
List<RecordingEntitlementsClient.EntitlementCall> calls = recorder.getIsEntitledToCalls();
assertEquals(1, calls.size());
RecordingEntitlementsClient.EntitlementCall call = calls.get(0);
assertEquals("user-123", ((UserSubjectContext) call.subject()).userId());
assertEquals("advanced-reporting",
((FeatureRequestContext) call.request()).featureKey());
// Assert on recorded lookup calls
List<LookupResourcesRequest> resourceCalls = recorder.getLookupResourcesCalls();
List<LookupSubjectsRequest> subjectCalls = recorder.getLookupSubjectsCalls();Call reset() to clear all recorded calls between test cases when reusing the same recorder:
recorder.reset();
assertTrue(recorder.getIsEntitledToCalls().isEmpty());Recorded call types:
| Method | Recorded in | Element type |
|---|---|---|
isEntitledTo |
getIsEntitledToCalls() |
EntitlementCall(subject, request) |
isEntitledToAsync |
getIsEntitledToCalls() |
EntitlementCall(subject, request) |
lookupResources |
getLookupResourcesCalls() |
LookupResourcesRequest |
lookupResourcesAsync |
getLookupResourcesCalls() |
LookupResourcesRequest |
lookupSubjects |
getLookupSubjectsCalls() |
LookupSubjectsRequest |
lookupSubjectsAsync |
getLookupSubjectsCalls() |
LookupSubjectsRequest |
All lists are unmodifiable views backed by thread-safe CopyOnWriteArrayList instances, so the
recorder is safe to use in multi-threaded test scenarios.
| Component | Supported Versions |
|---|---|
| Java | 17, 21 |
| Spring Boot | 3.2, 3.3, 3.4 |
| Quarkus | 3.x |
| Micronaut | 4.x |
| gRPC | 1.78.x (shaded Netty) |
| SLF4J | 2.x |
The library uses Java 17 language features (sealed interfaces, records, pattern matching) and cannot be used on Java 11 or earlier.
- Fork the repository and create a feature branch from
master. - Write or update unit tests for all changed behaviour.
- Ensure
mvn verifypasses locally before opening a pull request. - Follow the existing commit message format:
FR-<JIRA-KEY> - <description>. - Open a pull request against
masterwith a clear description of the change and its motivation.
# Unit tests only (no external dependencies)
mvn test
# Integration tests — requires Docker (uses Testcontainers to spin up a real SpiceDB instance)
mvn verify -Pintegration
# End-to-end tests — requires a running SpiceDB instance
mvn verify -Pe2e -Dspicedb.endpoint=<host:port> -Dspicedb.token=<token>For bug reports and feature requests, open a GitHub Issue with a minimal reproducer or a description of the desired behaviour.
This SDK is released under the MIT License.
Copyright (c) Frontegg Ltd.