Designing API Keys as Identities, Not Strings
How Quarkus enables feature-level authorization without spreading security logic across your codebase.
We lost a weekend to a feature flag that was “protected” by an API key check that only answered one question: does a key exist. Marketing had shipped a “free tier” link to a partner, and by Saturday morning we had paying-only features being exercised at scale because any valid key unlocked everything. The worst part was not the bill. The worst part was the debugging, because every service had its own idea of what an “API key” meant, and none of them could explain which features a request was supposed to be allowed to touch.
Distributed Java systems make this pain inevitable when you treat API keys as strings instead of identities. The moment you have multiple endpoints with different business risk, you need a key to become a principal with permissions, not a magic password that flips the entire API from “off” to “on”. And the moment you do that, you run into the real nuances: generation, storage, hashing, rotation, revocation, tamper resistance, and the fact that the “admin API” for keys is itself an attack surface.
What we’re going to build is the version of this that survives production: a Quarkus service that can issue keys, rotate them, revoke them, and map them to feature-level access control in a way that your endpoints can enforce without re-inventing security in every resource method.
Build the Skeleton You’ll Actually Keep
Prerequisites: Java 21, Maven, and a recent Quarkus CLI.
We’re going to pin this tutorial to Quarkus 3.30.5 (released Dec 24 🎄, 2025) so every artifact name matches what you type.
Run one bootstrap command or start from my Github repository:
quarkus create app com.mainthread.apikey:feature-key-service \
--java=21 \
--extensions="quarkus-rest-jackson,quarkus-hibernate-orm-panache,quarkus-jdbc-postgresql,quarkus-hibernate-validator,quarkus-security,quarkus-smallrye-openapi"
cd feature-key-serviceNow the extensions, briefly, because every one of them is there for a reason:
quarkus-rest-jackson: REST endpoints with JSON..quarkus-hibernate-orm-panache: persistence with clean transaction boundaries.quarkus-jdbc-postgresql: real database behavior, plus Dev Services in dev mode.quarkus-hibernate-validator: input validation for key creation and updates.quarkus-security: Quarkus SecurityIdentity and annotations.quarkus-smallrye-openapi: so the “admin API surface” is visible and reviewable.
Add this to src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
# Header name is a contract. Make it explicit.
app.apikey.header=X-API-Key
# Bootstrap admin key for the admin API.
# In real environments: inject via env var / secret manager.
app.apikey.bootstrap-admin=change-me-in-prod
# Pepper is a server-side secret mixed into hashing so DB leaks hurt less.
app.apikey.pepper=change-me-tooThat drop-and-create setting is only for this tutorial’s “ship it today” flow. In a real system you’d move to migrations early, because key storage is not the part you want schema surprises in.
The Key Model That Doesn’t Hate You Later
The first mistake teams make is storing API keys in plaintext because “we need to show it to the user”. The second mistake is thinking hashing is enough without designing for rotation and partial compromise. We’re going to store only a hash, we’re going to show the raw key exactly once, and we’re going to model “features” as roles so Quarkus can enforce them at the endpoint layer.
ApiKeyEntity
Create src/main/java/com/mainthread/apikey/keys/ApiKeyEntity.java:
package com.mainthread.apikey.keys;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "api_keys", uniqueConstraints = {
@UniqueConstraint(name = "uk_api_keys_key_id", columnNames = "keyId")
})
public class ApiKeyEntity extends PanacheEntityBase {
@Id
@GeneratedValue
public Long id;
/**
* Public identifier used in logs and admin operations.
* Not secret, but unique and stable across rotations if you choose to keep it.
*/
@Column(nullable = false, updatable = false, length = 64)
public String keyId;
/**
* Hash of the secret part (never store plaintext).
*/
@Column(nullable = false, length = 256)
public String keyHash;
@Column(nullable = false)
public boolean active = true;
@Column(nullable = false, updatable = false)
public Instant createdAt = Instant.now();
@Column(nullable = false)
public Instant lastUsedAt = Instant.EPOCH;
/**
* Feature-level access expressed as roles.
* Example: "catalog:read", "catalog:write", "pricing:read".
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "api_key_features", joinColumns = @JoinColumn(name = "api_key_fk"))
@Column(name = "feature", nullable = false, length = 80)
public Set<String> features = new HashSet<>();
}This entity keeps two identities on purpose: keyId is what you can safely log and search, and keyHash is what your authentication pipeline actually verifies. You’ll notice features are eager, because authorization is part of request authentication, and lazy-loading here is how you invent a production-only bug under load when sessions close early.
Generating Keys That You Can Rotate Without Drama
Key generation is not “random string plus DB insert”. You want a format that you can validate quickly, you want an identifier you can log without leaking secrets, and you want hashing that is consistent but not reversible.
ApiKeyCrypto
Create src/main/java/com/mainthread/apikey/keys/ApiKeyCrypto.java:
package com.mainthread.apikey.keys;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ApiKeyCrypto {
private final SecureRandom secureRandom = new SecureRandom();
@ConfigProperty(name = "app.apikey.pepper")
String pepper;
public GeneratedKey generate() {
// A short, loggable keyId and a longer secret.
String keyId = randomUrlSafe(12);
String secret = randomUrlSafe(32);
// What clients store and send. What you store is only the hash of the secret.
String presented = "mtk_" + keyId + "_" + secret;
return new GeneratedKey(keyId, secret, presented, hashSecret(secret));
}
public String hashSecret(String secret) {
// SHA-256 + pepper is a pragmatic baseline.
// If your threat model includes offline cracking of weak secrets, use a slow
// hash (Argon2/bcrypt).
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = (pepper + ":" + secret).getBytes(StandardCharsets.UTF_8);
byte[] hashed = digest.digest(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed);
} catch (Exception e) {
throw new IllegalStateException("Unable to hash API key secret", e);
}
}
private String randomUrlSafe(int bytes) {
byte[] raw = new byte[bytes];
secureRandom.nextBytes(raw);
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
}
public record GeneratedKey(String keyId, String secret, String presentedKey, String secretHash) {
}
}The format mtk_<keyId>_<secret> is doing work for you. When a bug report drops a header value into your logs, you can reject it on shape alone, and you can choose to log only the keyId segment without training every engineer to do redaction perfectly.
The Admin API That Issues, Rotates, and Revokes
This is where teams often create their third mistake: an admin endpoint that returns stored keys. We’re going to return the raw key only at creation or rotation time, and we’ll never persist it.
DTOs
Create src/main/java/com/mainthread/apikey/admin/dto/CreateKeyRequest.java:
package com.mainthread.apikey.admin.dto;
import java.util.Set;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
public record CreateKeyRequest(
@NotEmpty @Size(max = 20) Set<@Size(min = 3, max = 80) String> features) {
}Create src/main/java/com/mainthread/apikey/admin/dto/CreateKeyResponse.java:
package com.mainthread.apikey.admin.dto;
import java.time.Instant;
import java.util.Set;
public record CreateKeyResponse(
String keyId,
String apiKey,
Set<String> features,
Instant createdAt) {
}Create src/main/java/com/mainthread/apikey/admin/dto/RotateKeyResponse.java:
package com.mainthread.apikey.admin.dto;
import java.time.Instant;
public record RotateKeyResponse(
String keyId,
String newApiKey,
Instant rotatedAt) {
}ApiKeyAdminService with Transaction Boundaries
Create src/main/java/com/mainthread/apikey/admin/ApiKeyAdminService.java:
package com.mainthread.apikey.admin;
import java.time.Instant;
import java.util.Set;
import com.mainthread.apikey.admin.dto.CreateKeyRequest;
import com.mainthread.apikey.admin.dto.CreateKeyResponse;
import com.mainthread.apikey.admin.dto.RotateKeyResponse;
import com.mainthread.apikey.keys.ApiKeyCrypto;
import com.mainthread.apikey.keys.ApiKeyEntity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ApiKeyAdminService {
private final ApiKeyCrypto crypto;
public ApiKeyAdminService(ApiKeyCrypto crypto) {
this.crypto = crypto;
}
@Transactional
public CreateKeyResponse create(CreateKeyRequest request) {
ApiKeyCrypto.GeneratedKey key = crypto.generate();
ApiKeyEntity entity = new ApiKeyEntity();
entity.keyId = key.keyId();
entity.keyHash = key.secretHash();
entity.active = true;
entity.createdAt = Instant.now();
entity.lastUsedAt = Instant.EPOCH;
entity.features.addAll(request.features());
entity.persist();
return new CreateKeyResponse(
entity.keyId,
key.presentedKey(),
Set.copyOf(entity.features),
entity.createdAt);
}
@Transactional
public void revoke(String keyId) {
ApiKeyEntity entity = ApiKeyEntity.find("keyId", keyId).firstResult();
if (entity == null) {
return;
}
entity.active = false;
}
@Transactional
public RotateKeyResponse rotate(String keyId) {
ApiKeyEntity entity = ApiKeyEntity.find("keyId", keyId).firstResult();
if (entity == null) {
throw new IllegalArgumentException("Unknown keyId: " + keyId);
}
ApiKeyCrypto.GeneratedKey newKey = crypto.generate();
entity.keyHash = newKey.secretHash();
entity.active = true;
return new RotateKeyResponse(entity.keyId, newKey.presentedKey(), Instant.now());
}
}The @Transactional annotations are not decoration. They’re the line between “this works in dev mode” and “this fails under concurrency because entity state is detached when you update lastUsedAt during auth”.
Admin Resource
Create src/main/java/com/mainthread/apikey/admin/ApiKeyAdminResource.java:
package com.mainthread.apikey.admin;
import com.mainthread.apikey.admin.dto.CreateKeyRequest;
import com.mainthread.apikey.admin.dto.CreateKeyResponse;
import com.mainthread.apikey.admin.dto.RotateKeyResponse;
import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
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;
@Path("/admin/api-keys")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("admin")
public class ApiKeyAdminResource {
private final ApiKeyAdminService service;
public ApiKeyAdminResource(ApiKeyAdminService service) {
this.service = service;
}
@POST
public CreateKeyResponse create(@Valid CreateKeyRequest request) {
return service.create(request);
}
@POST
@Path("/{keyId}/revoke")
public void revoke(@PathParam("keyId") String keyId) {
service.revoke(keyId);
}
@POST
@Path("/{keyId}/rotate")
public RotateKeyResponse rotate(@PathParam("keyId") String keyId) {
return service.rotate(keyId);
}
}This endpoint layer reads like policy because authorization is policy. The actual enforcement comes next, when we teach Quarkus how to turn an API key into a SecurityIdentity with feature roles.
Authentication That Produces Feature-Level Authorization
Quarkus security is built around the idea that authentication yields a SecurityIdentity, and authorization checks read from it. That means our API key logic belongs in the authentication pipeline, not in every resource method.
ApiKeyAuthenticationRequest
Create src/main/java/com/mainthread/apikey/security/ApiKeyAuthenticationRequest.java:
package com.mainthread.apikey.security;
import java.util.HashMap;
import java.util.Map;
import io.quarkus.security.identity.request.AuthenticationRequest;
public class ApiKeyAuthenticationRequest implements AuthenticationRequest {
private final String rawApiKey;
private final Map<String, Object> attributes = new HashMap<>();
public ApiKeyAuthenticationRequest(String rawApiKey) {
this.rawApiKey = rawApiKey;
}
public String rawApiKey() {
return rawApiKey;
}
@Override
public void setAttribute(String name, Object value) {
attributes.put(name, value);
}
@Override
public Object getAttribute(String name) {
return attributes.get(name);
}
public Map<String, Object> getAttributes() {
return attributes;
}
}ApiKeyAuthenticationMechanism
Create src/main/java/com/mainthread/apikey/security/ApiKeyAuthenticationMechanism.java:
package com.mainthread.apikey.security;
import java.util.Optional;
import java.util.Set;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.AnonymousAuthenticationRequest;
import io.quarkus.security.identity.request.AuthenticationRequest;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ApiKeyAuthenticationMechanism implements HttpAuthenticationMechanism {
@ConfigProperty(name = "app.apikey.header", defaultValue = "X-API-Key")
String headerName;
@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
String value = context.request().getHeader(headerName);
if (value == null || value.isBlank()) {
return identityProviderManager.authenticate(AnonymousAuthenticationRequest.INSTANCE);
}
AuthenticationRequest request = new ApiKeyAuthenticationRequest(value.trim());
return identityProviderManager.authenticate(request);
}
@Override
public Uni<ChallengeData> getChallenge(RoutingContext context) {
// For API keys we usually avoid browser-like challenges.
return Uni.createFrom().optional(Optional.empty());
}
@Override
public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
return Set.of(ApiKeyAuthenticationRequest.class, AnonymousAuthenticationRequest.class);
}
}This mechanism does one thing: extract the header and delegate. That separation matters because it keeps HTTP concerns away from the identity logic, which makes testing and future reuse across transports much less painful.
ApiKeyIdentityProvider that loads features as roles
Create src/main/java/com/mainthread/apikey/security/ApiKeyIdentityProvider.java:
package com.mainthread.apikey.security;
import java.security.Principal;
import java.time.Instant;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.mainthread.apikey.keys.ApiKeyCrypto;
import com.mainthread.apikey.keys.ApiKeyEntity;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class ApiKeyIdentityProvider implements IdentityProvider<ApiKeyAuthenticationRequest> {
private final ApiKeyCrypto crypto;
@ConfigProperty(name = "app.apikey.bootstrap-admin")
String bootstrapAdminKey;
public ApiKeyIdentityProvider(ApiKeyCrypto crypto) {
this.crypto = crypto;
}
@Override
public Class<ApiKeyAuthenticationRequest> getRequestType() {
return ApiKeyAuthenticationRequest.class;
}
@Override
public Uni<SecurityIdentity> authenticate(ApiKeyAuthenticationRequest request,
AuthenticationRequestContext context) {
return context.runBlocking(() -> authenticateBlocking(request.rawApiKey()));
}
@Transactional
SecurityIdentity authenticateBlocking(String rawApiKey) {
// Bootstrap admin: lets you manage keys before you have keys.
if (rawApiKey.equals(bootstrapAdminKey)) {
return QuarkusSecurityIdentity.builder()
.setPrincipal(simplePrincipal("bootstrap-admin"))
.addRole("admin")
.build();
}
ParsedKey parsed = ParsedKey.parse(rawApiKey);
if (parsed == null) {
return null;
}
ApiKeyEntity entity = ApiKeyEntity.find("keyId", parsed.keyId()).firstResult();
if (entity == null || !entity.active) {
return null;
}
String computed = crypto.hashSecret(parsed.secret());
if (!computed.equals(entity.keyHash)) {
return null;
}
entity.lastUsedAt = Instant.now();
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder()
.setPrincipal(simplePrincipal("apikey:" + entity.keyId));
for (String feature : entity.features) {
builder.addRole(feature);
}
return builder.build();
}
private Principal simplePrincipal(String name) {
return () -> name;
}
record ParsedKey(String keyId, String secret) {
static ParsedKey parse(String raw) {
// Expected: mtk_<keyId>_<secret>
if (raw == null || !raw.startsWith("mtk_")) {
return null;
}
String[] parts = raw.split("_", 3);
if (parts.length != 3) {
return null;
}
if (parts[1].isBlank() || parts[2].isBlank()) {
return null;
}
return new ParsedKey(parts[1], parts[2]);
}
}
}There are two deliberate choices here that are easy to miss until you’ve been burned. We parse out a non-secret keyId so database lookups stay indexed and predictable, and we update lastUsedAt inside a transaction because authentication is still a data mutation if you want forensic trails. If you try to do that update “somewhere else later”, you end up lying to yourself about when a compromised key was actually used.
Feature Endpoints That Read Like Business Policy
Now we can write endpoints that declare intent and let the security layer enforce the messy parts. This is where feature-level access control stops being theoretical.
Create src/main/java/com/mainthread/apikey/features/CatalogResource.java:
package com.mainthread.apikey.features;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/catalog")
@Produces(MediaType.APPLICATION_JSON)
public class CatalogResource {
@GET
@Path("/products")
@RolesAllowed("catalog:read")
public String products() {
return "{\"status\":\"ok\",\"data\":\"visible to catalog:read\"}";
}
@GET
@Path("/admin-report")
@RolesAllowed("catalog:admin")
public String adminReport() {
return "{\"status\":\"ok\",\"data\":\"visible to catalog:admin\"}";
}
}In production this is where teams usually start smearing if (apiKey.hasFeature(...)) into every handler. You can do that, and you’ll ship faster for a month, and then you’ll spend the next year trying to audit it. Declaring permissions at the boundary is the only scalable way to reason about exposure when you have dozens of endpoints and multiple client types.
Production Hardening Where the Nuances Actually Live
This implementation works, but production punishes the edges, not the happy path. The first pressure point is brute-force and enumeration risk: the keyId lookup is fast, which is good for latency, but also means an attacker can hammer the index unless you rate limit and alert. The second pressure point is hashing: SHA-256 plus pepper is a pragmatic baseline for high-entropy secrets, but if your operational reality includes keys being copied into spreadsheets and truncated, you’ll want a slow hash to defend against offline guessing after a database leak.
Rotation and revocation are where teams usually break their own guarantees. We rotate by replacing the stored hash, which means existing clients break immediately unless you introduce a grace window with dual hashes. That dual-hash window is not “hard”, but it expands your attack window and your database model, so you only add it when your clients cannot rotate quickly.
Finally, the admin bootstrap key is a controlled sin. It exists because systems need a birth story, but in a narrowly scoped example we accept it and push the real solution to production practice: store it in a secret manager, restrict its network path, and plan how you retire it once you have your first real admin key.
Verification That Looks Like How You’d Debug It
Start the app:
quarkus devCreate a key using the bootstrap admin key:
curl -s -X POST "http://localhost:8080/admin/api-keys" \
-H "Content-Type: application/json" \
-H "X-API-Key: change-me-in-prod" \
-d '{"features":["catalog:read"]}'Expected output shape:
{
"keyId": "9Typl-eirWmzEDjT",
"apiKey": "mtk_9Typl-eirWmzEDjT_zB8Ou0gV-Q6XKJFPiw3DkaH_dfldOaOaVLr7ADNb__s",
"features": [
"catalog:read"
],
"createdAt": "2025-12-27T05:00:01.658220Z"
}Call a protected endpoint with the issued key:
curl -s "http://localhost:8080/catalog/products" \
-H "X-API-Key: mtk_YOUR_KEYID_YOUR_SECRET"Expected output:
{
"status": "ok",
"data": "visible to catalog:read"
}Now try an endpoint you did not grant:
curl -i "http://localhost:8080/catalog/admin-report" \
-H "X-API-Key: mtk_YOUR_KEYID_YOUR_SECRET"Expected status is a denial:
HTTP/1.1 403 ForbiddenThat 403 is the whole point. The system isn’t checking whether you’re “authenticated”. It’s checking whether the key is allowed to touch the feature you’re asking for.
Closing the Loop
You now have an API key system that treats keys as principals with feature roles, issues secrets only once, stores hashes instead of plaintext, and enforces access at the endpoint boundary where it can be audited. The difference from the naïve “key exists” check is that you can reason about blast radius before a partner link turns into a weekend incident.
The safest API key is the one that can’t accidentally become an all-access badge.
Read more on APIs:
Versioning APIs in Quarkus: Four Strategies Every Java Developer Should Know
APIs are living contracts. As our applications grow, their APIs must evolve too. New features arrive, old fields become obsolete, and entire data structures may need rethinking. But breaking existing client integrations is not an option. That’s where





Fantastic writeup on treating keys as principals instead of just auth tokens. The featurelevel access control mapped to roles is exactly what most systems are missing, they just check if a key exists and call it a day. We had a similar issue where a partner key was supposed to be read-only but ended up triggering write operations, cost us a whole weekend debugging becasue nobody could trace which service granted what permissions. Your bootstrap admin pattern is clean tho, better than hardcoding admin checks everywhere.