The Secret Agent’s Guide to API Key & Token Management with Quarkus
How to Secure Supersonic APIs Using API Keys, and a Few Spy Tricks
Modern software agents need more than speed. They need stealth. In this hands-on guide, you’ll build a secure, high-performance API key and token management system with Quarkus. From key generation and usage tracking to JWT-protected endpoints and rate limiting, this mission walks you through every step needed to secure your APIs like a true agent of the JVM.
Mission Setup: Your Secret Base
Create a new project with the essential Quarkus extensions:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.secretagent \
-DprojectArtifactId=api-key-manager \
-DclassName="com.secretagent.ApiKeyResource" \
-Dpath="/api/keys" \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-h2,scheduler,smallrye-openapi,smallrye-health"
cd api-key-manager
As usual, you can find the complete soure-code in my Github repository.
Some configuration
Let’s add some configuration to the src/main/resources/application.properties
so we get this out of the way.
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.log.sql=true
quarkus.smallrye-openapi.info-title=Secret Agent API Key Manager
quarkus.smallrye-openapi.info-version=1.0.0
quarkus.smallrye-openapi.info-description=Top-secret API key management system
# Logging
quarkus.log.category."com.secretagent".level=DEBUG
Creating the API Key Entity
Define the database model in src/main/java/com/secretagent/ApiKey.java
.
package com.secretagent;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Entity
@Table(name = "api_keys")
public class ApiKey extends PanacheEntity {
@Column(unique = true, nullable = false)
public String keyValue;
@Column(nullable = false)
public String name;
@Column(nullable = false)
public String owner;
@Column(nullable = false)
public LocalDateTime createdAt;
@Column
public LocalDateTime lastUsed;
@Column(nullable = false)
public Boolean active = true;
@Column(nullable = false)
public Long usageCount = 0L;
@Column
public LocalDateTime expiresAt;
@Column
public String permissions; // JSON string for simplicity
// Custom finder methods - because every agent needs their tools
public static Optional<ApiKey> findByKeyValue(String keyValue) {
return find("keyValue", keyValue).firstResultOptional();
}
public static List<ApiKey> findByOwner(String owner) {
return find("owner", owner).list();
}
public static List<ApiKey> findActiveKeys() {
return find("active", true).list();
}
public static List<ApiKey> findExpiredKeys() {
return find("expiresAt < ?1 and active = true", LocalDateTime.now()).list();
}
// The secret sauce - increment usage
public void recordUsage() {
this.usageCount++;
this.lastUsed = LocalDateTime.now();
this.persist();
}
// Check if this key is still valid
public boolean isValid() {
return active && (expiresAt == null || expiresAt.isAfter(LocalDateTime.now()));
}
}
This class tracks key metadata, expiration, and usage counts. Make sure keyValue
is unique and persisted.
This includes methods for custom queries (findByOwner
, findExpiredKeys
, etc.) and helper logic like isValid()
and recordUsage()
.
Key Generation Service (a.k.a. Q Division)
Create src/main/java/com/secretagent/ApiKeyService.java
:
package com.secretagent;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ApiKeyService {
private static final String KEY_PREFIX = "ak_";
private static final int KEY_LENGTH = 32;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
public ApiKey createApiKey(String name, String owner, Integer validityDays, String permissions) {
ApiKey apiKey = new ApiKey();
apiKey.keyValue = generateSecureKey();
apiKey.name = name;
apiKey.owner = owner;
apiKey.createdAt = LocalDateTime.now();
apiKey.permissions = permissions;
if (validityDays != null && validityDays > 0) {
apiKey.expiresAt = LocalDateTime.now().plusDays(validityDays);
}
apiKey.persist();
return apiKey;
}
private String generateSecureKey() {
byte[] randomBytes = new byte[KEY_LENGTH];
secureRandom.nextBytes(randomBytes);
return KEY_PREFIX + Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(randomBytes);
}
@Transactional
public Optional<ApiKey> rotateKey(Long keyId) {
Optional<ApiKey> existingKey = ApiKey.findByIdOptional(keyId);
if (existingKey.isEmpty()) {
return Optional.empty();
}
ApiKey oldKey = existingKey.get();
// Create new key with same properties
ApiKey newKey = createApiKey(
oldKey.name + " (rotated)",
oldKey.owner,
oldKey.expiresAt != null
? (int) java.time.Duration.between(LocalDateTime.now(), oldKey.expiresAt).toDays()
: null,
oldKey.permissions);
// Deactivate old key
oldKey.active = false;
oldKey.persist();
return Optional.of(newKey);
}
@Transactional
public boolean validateAndRecordUsage(String keyValue) {
Optional<ApiKey> apiKey = ApiKey.findByKeyValue(keyValue);
if (apiKey.isEmpty() || !apiKey.get().isValid()) {
return false;
}
apiKey.get().recordUsage();
return true;
}
@Transactional
public void deactivateExpiredKeys() {
List<ApiKey> expiredKeys = ApiKey.findExpiredKeys();
expiredKeys.forEach(key -> {
key.active = false;
key.persist();
});
}
public List<ApiKey> getKeysByOwner(String owner) {
return ApiKey.findByOwner(owner);
}
public List<ApiKey> getAllActiveKeys() {
return ApiKey.findActiveKeys();
}
}
This service handles API key creation, secure random key generation, rotation, and expiration. You’ll use a SecureRandom
source and Base64 to generate keys prefixed with ak_
.
The service also provides methods like validateAndRecordUsage
, deactivateExpiredKeys
, and basic owner-based queries.
REST API Control Panel
Update src/main/java/com/secretagent/ApiKeyResource.java
:
package com.secretagent;
import java.util.List;
import java.util.Optional;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/keys")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "API Key Management", description = "Secret Agent API Key Operations")
public class ApiKeyResource {
@Inject
ApiKeyService apiKeyService;
@POST
@Operation(summary = "Create a new API key", description = "Generate a new API key for an agent")
public Response createApiKey(CreateApiKeyRequest request) {
try {
ApiKey apiKey = apiKeyService.createApiKey(
request.name,
request.owner,
request.validityDays,
request.permissions);
return Response.status(Response.Status.CREATED)
.entity(new ApiKeyResponse(apiKey))
.build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("Failed to create API key: " + e.getMessage()))
.build();
}
}
@GET
@Path("/owner/{owner}")
@Operation(summary = "Get keys by owner", description = "Retrieve all keys for a specific agent")
public List<ApiKeyResponse> getKeysByOwner(@PathParam("owner") String owner) {
return apiKeyService.getKeysByOwner(owner)
.stream()
.map(ApiKeyResponse::new)
.toList();
}
@POST
@Path("/{keyId}/rotate")
@Operation(summary = "Rotate an API key", description = "Create a new key and deactivate the old one")
public Response rotateKey(@PathParam("keyId") Long keyId) {
Optional<ApiKey> newKey = apiKeyService.rotateKey(keyId);
if (newKey.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("API key not found"))
.build();
}
return Response.ok(new ApiKeyResponse(newKey.get())).build();
}
@POST
@Path("/validate")
@Operation(summary = "Validate API key", description = "Check if key is valid and record usage")
public Response validateKey(ValidateKeyRequest request) {
boolean isValid = apiKeyService.validateAndRecordUsage(request.keyValue);
return Response.ok(new ValidationResponse(isValid)).build();
}
@GET
@Path("/stats")
@Operation(summary = "Get API key statistics", description = "Get usage statistics for all keys")
public Response getStats() {
List<ApiKey> activeKeys = apiKeyService.getAllActiveKeys();
long totalUsage = activeKeys.stream().mapToLong(key -> key.usageCount).sum();
return Response.ok(new StatsResponse(
activeKeys.size(),
totalUsage,
activeKeys.stream().mapToLong(key -> key.usageCount).max().orElse(0))).build();
}
// DTOs for our secret communications
public static class CreateApiKeyRequest {
public String name;
public String owner;
public Integer validityDays;
public String permissions;
}
public static class ValidateKeyRequest {
public String keyValue;
}
public static class ApiKeyResponse {
public Long id;
public String keyValue;
public String name;
public String owner;
public String createdAt;
public String lastUsed;
public Boolean active;
public Long usageCount;
public String expiresAt;
public ApiKeyResponse(ApiKey apiKey) {
this.id = apiKey.id;
this.keyValue = apiKey.keyValue;
this.name = apiKey.name;
this.owner = apiKey.owner;
this.createdAt = apiKey.createdAt.toString();
this.lastUsed = apiKey.lastUsed != null ? apiKey.lastUsed.toString() : null;
this.active = apiKey.active;
this.usageCount = apiKey.usageCount;
this.expiresAt = apiKey.expiresAt != null ? apiKey.expiresAt.toString() : null;
}
}
public static class ValidationResponse {
public boolean valid;
public String message;
public ValidationResponse(boolean valid) {
this.valid = valid;
this.message = valid ? "Access granted, Agent!" : "Access denied. Invalid credentials.";
}
}
public static class StatsResponse {
public int totalActiveKeys;
public long totalUsage;
public long maxUsage;
public StatsResponse(int totalActiveKeys, long totalUsage, long maxUsage) {
this.totalActiveKeys = totalActiveKeys;
this.totalUsage = totalUsage;
this.maxUsage = maxUsage;
}
}
public static class ErrorResponse {
public String error;
public ErrorResponse(String error) {
this.error = error;
}
}
}
The ApiKeyResource
class exposes endpoints for:
Creating new keys
Fetching keys by owner
Rotating keys
Validating a key
Viewing usage stats
It uses DTOs for clean JSON responses and error handling. Validation increments the usage counter and checks for expiration or deactivation.
Scheduled Maintenance
Create src/main/java/com/secretagent/MaintenanceScheduler.java
:
package com.secretagent;
import org.jboss.logging.Logger;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class MaintenanceScheduler {
private static final Logger LOG = Logger.getLogger(MaintenanceScheduler.class);
@Inject
ApiKeyService apiKeyService;
@Scheduled(every = "1h") // Every hour, like a Swiss watch
void cleanupExpiredKeys() {
LOG.info("🧹 Starting expired key cleanup mission...");
apiKeyService.deactivateExpiredKeys();
LOG.info("Expired key cleanup completed!");
}
@Scheduled(cron = "0 0 9 * * ?") // Daily at 9 AM
void dailyReport() {
LOG.info("📊 Daily intelligence report:");
var activeKeys = apiKeyService.getAllActiveKeys();
var totalUsage = activeKeys.stream().mapToLong(key -> key.usageCount).sum();
LOG.infof("Active keys: %d, Total usage: %d", activeKeys.size(), totalUsage);
}
}
To clean up expired keys hourly and log a daily usage report. Use Quarkus Scheduler with annotations like @Scheduled(every = "1h")
and @Scheduled(cron = "0 0 9 * * ?")
.
Testing the System
Start the application
quarkus dev
1. Create API Key
curl -X 'POST' \
'http://localhost:8080/api/keys' \
-H 'Content-Type: application/json' \
-d '{
"name": "Service Key Alpha",
"owner": "mission.control",
"validityDays": 30,
"permissions": "string"
}'
2. Validate the Key
curl -X 'POST' \
'http://localhost:8080/api/keys/validate' \
-H 'Content-Type: application/json' \
-d '{
"keyValue": "YOUR_KEY"
}'
3. Get Statistics
curl http://localhost:8080/api/keys/stats
4. Rotate a Key
curl -X POST http://localhost:8080/api/keys/1/rotate
Take a look at the full API with Swagger at http://localhost:8080/q/swagger-ui/
Rate Limiting: One Key, One Minute, One Hundred Queries
No agent should hog the comms channel. The RateLimitingInterceptor
is your API’s frontline defense against greedy or malfunctioning clients. Acting as a JAX-RS provider, it intercepts every incoming request and checks for the X-API-Key
header.
Create src/main/java/com/secretagent/RateLimitingInterceptor.java
:
package com.secretagent;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
@Provider
public class RateLimitingInterceptor implements ContainerRequestFilter {
private final ConcurrentHashMap<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS_PER_MINUTE = 100;
@Override
public void filter(ContainerRequestContext requestContext) {
String apiKey = requestContext.getHeaderString("X-API-Key");
if (apiKey != null) {
AtomicInteger count = requestCounts.computeIfAbsent(apiKey, k -> new AtomicInteger(0));
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
requestContext.abortWith(
Response.status(Response.Status.TOO_MANY_REQUESTS)
.entity(new ApiKeyResource.ErrorResponse("Rate limit exceeded, Agent!"))
.build()
);
}
}
}
}
Behind the scenes, it uses a thread-safe counter to track how many calls each key has made in the last minute. If that number exceeds 100, the interceptor immediately cuts off the request with a 429 Too Many Requests
response. Along with a polite but firm message that the agent needs to slow down.
This lightweight mechanism enforces fairness, preserves system stability, and keeps rogue keys from jamming the frequency.
Health Check: Is the Key Vault Operational?
Every secure facility needs a status panel. The ApiKeyHealthCheck
class is our way of reporting to mission control whether the API Key system is running smoothly. It’s a Quarkus component that plugs into the MicroProfile Health system and acts as a readiness check, used by probes or load balancers to determine if the application is ready for action.
Create src/main/java/com/secretagent/ApiKeyHealthCheck.java
:
package com.secretagent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Readiness;
@Readiness
@ApplicationScoped
public class ApiKeyHealthCheck implements HealthCheck {
@Inject
ApiKeyService apiKeyService;
@Override
public HealthCheckResponse call() {
try {
int activeKeys = apiKeyService.getAllActiveKeys().size();
return HealthCheckResponse.named("API Key Service")
.up()
.withData("activeKeys", activeKeys)
.build();
} catch (Exception e) {
return HealthCheckResponse.named("API Key Service")
.down()
.withData("error", e.getMessage())
.build();
}
}
}
This agent taps into the ApiKeyService
via CDI, attempts to count the number of active keys, and reports one of two outcomes:
If everything checks out, it returns
UP
and includes the count of active keys.If anything fails, it triggers an alarm with
DOWN
status and an error message, allowing your observability systems to raise alerts or divert traffic.
You can check the SmallRye Health UI for the status (http://localhost:8080/q/health-ui/):
Mission Accomplished
You’ve successfully:
Built an API key lifecycle manager
Tracked usage and supported key rotation
Cleaned up expired keys on schedule
Applied rate limiting and health monitoring
You're now ready to protect any Quarkus-powered application with elegance and stealth. The JVM thanks you, Agent.
Swagger UI: http://localhost:8080/q/swagger-ui/
Dev UI: http://localhost:8080/q/dev/
Health Check: http://localhost:8080/q/health