Quarkus TOTP with Vault: What Production-Ready Step-Up Requires
Separate first-factor login, TOTP verification, and authorization while addressing replay, recovery, Vault policy, and outage behavior.
Quarkus Insights #253 lists HashiCorp Vault among the 20 most-used Quarkiverse extensions in the opt-in build analytics. Its usage also increased notably from May 2025 to May 2026. More Quarkus applications are using Vault, so this is a good time to revisit the integration.
I first covered it in the short May 2025 article Add TOTP Authentication to Your Java API with Quarkus and Vault Dev Services. That example started Vault with Dev Services, created a TOTP key, displayed a QR code, and validated a six-digit code.
That was enough to show the Vault API, but not a complete authentication flow. The example trusted an X-User header and sent the TOTP code directly to the protected endpoint. It had no first-factor token, enrollment state, or replay test. It also required a manual step in the Vault UI to enable the TOTP engine.
This article builds the missing parts. A signed access token represents the first login. The user exchanges one valid TOTP code for a step-up JWT that expires after two minutes. Only that token carries the payout:approve role. The payout endpoint then uses normal Quarkus RBAC. The TOTP code raises assurance once; it does not become another credential on every business request.
We also store the enrollment mapping, mark the QR response as no-store, validate the code format, and test that Vault rejects a used code.
One version detail matters for this setup. Quarkus Vault 4.8.0 is current as I write this, but its Dev Services source still defaults to Vault 1.15.2. Vault fixed a TOTP code-reuse vulnerability in 1.15.15, 1.16.23, 1.18.12, 1.19.7, and 1.20.1. The Java extension and the Vault server have separate versions. We will set the current Vault 2.0.3 image explicitly.
What we are building
The request flow has three credentials with different jobs:
A normal bearer token represents the authenticated subject
aliceand carries theuserrole.A valid TOTP code raises assurance. LedgerLock consumes it once and returns a step-up JWT that expires after 120 seconds.
The step-up JWT carries
payout:approve, so Quarkus allows the sensitive operation.
The step-up token also contains "amr": ["pwd", "otp"]. The names come from RFC 8176, which defines authentication-method reference values that a relying service can inspect. amr describes how the user authenticated. The role still decides what the token may do.
LedgerLock issues both tokens so the example stays local. The development token endpoint simulates the result of a first-factor login; it does not verify a password. In a system with Keycloak, Auth0, Entra ID, or another OpenID Connect provider, that provider would normally own the first factor and often the second one too.
What you need
This article uses Quarkus 3.37.2, Quarkus Vault 4.8.0, Java 21, and Vault 2.0.3.
Java 25 or newer
The Quarkus CLI
Podman with a running machine
curlandjqAn authenticator app that supports TOTP
About ☕️☕️☕️☕️ (< security topics are hard!)
Create the application
Create the project or start from my Github repository:
quarkus create app com.themainthread:vault-totp-step-up \
--extension='rest-jackson,smallrye-jwt,smallrye-jwt-build,hibernate-validator,hibernate-orm-panache,jdbc-h2,smallrye-health' \
--no-codequarkus-rest-jackson gives us the JSON endpoints. SmallRye JWT verifies the bearer tokens and signs the local step-up token. Hibernate Validator rejects malformed TOTP input before it reaches Vault. Panache and H2 keep the subject-to-Vault-key mapping for this runnable example, and SmallRye Health exposes the Vault readiness check.
Vault is a Quarkiverse extension, so add its current version explicitly:
<dependency>
<groupId>io.quarkiverse.vault</groupId>
<artifactId>quarkus-vault</artifactId>
<version>4.8.0</version>
</dependency>Configure Vault Dev Services
Add the following to src/main/resources/application.properties:
# Vault Dev Services starts only in dev and test mode.
quarkus.vault.devservices.image-name=hashicorp/vault:2.0.3
quarkus.vault.devservices.shared=false
quarkus.vault.devservices.init-commands=secrets enable totp
quarkus.vault.health.enabled=true
# The tutorial keeps enrollment state local so it needs no second container.
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:ledgerlock;DB_CLOSE_DELAY=-1
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
# SmallRye JWT generates a signing key pair automatically in dev and test mode.
mp.jwt.verify.issuer=https://ledgerlock.local
mp.jwt.verify.audiences=ledgerlock-api
mp.jwt.verify.clock.skew=5
smallrye.jwt.new-token.issuer=https://ledgerlock.local
smallrye.jwt.new-token.audience=ledgerlock-api
smallrye.jwt.new-token.lifespan=300
# Vault is a request dependency for enrollment and verification.
quarkus.vault.connect-timeout=3S
quarkus.vault.read-timeout=3SDev Services already knows how to start Vault and inject the URL and root token in dev and test mode. It does not enable the TOTP secrets engine by default. init-commands passes secrets enable totp to the container during startup, which removes the manual Vault UI step that older examples often needed.
Set the image explicitly. HashiCorp recommends using the latest fix release within a supported line, and the old 1.15.2 default predates the TOTP replay fix. Vault 2.0.3 is the current release, and the test below runs against it. Vault 2.x contains breaking changes, so review the 2.x release notes and upgrade guidance before changing an existing production cluster.
Quarkus also generates an in-memory JWT key pair in dev and test mode. Production needs a real signing key, a JWK set, or an identity provider. The build prints a warning when no production key is configured.
Keep enrollment state outside the TOTP seed
Vault stores the TOTP seed and performs code validation. LedgerLock still needs to know which opaque Vault key belongs to each authenticated subject. Create TotpEnrollment.java:
package com.themainthread.ledgerlock;
import java.time.Instant;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "totp_enrollment")
public class TotpEnrollment extends PanacheEntityBase {
@Id
@Column(nullable = false, updatable = false)
public String subject;
@Column(name = "vault_key", nullable = false, unique = true, updatable = false)
public String vaultKey;
@Column(name = "created_at", nullable = false, updatable = false)
public Instant createdAt;
}The database stores no TOTP seed. vaultKey is a random identifier such as user-86483632-4d7b-498f-ae59-b4998d62b53e. Using an opaque value keeps email addresses and usernames out of Vault paths and audit entries.
An enrollment already present for the subject returns 409 Conflict. Rotation needs its own flow that asks for an existing factor or a recovery credential. Otherwise, a stolen access token would be enough to replace the second factor.
Put the Vault interaction in one service
Create TotpService.java:
package com.themainthread.ledgerlock;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.jboss.logging.Logger;
import io.quarkus.vault.VaultTOTPSecretEngine;
import io.quarkus.vault.client.VaultClientException;
import io.quarkus.vault.secrets.totp.CreateKeyParameters;
import io.quarkus.vault.secrets.totp.KeyDefinition;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.PersistenceException;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;
@ApplicationScoped
public class TotpService {
private static final Logger LOG = Logger.getLogger(TotpService.class);
private final VaultTOTPSecretEngine totpEngine;
public TotpService(VaultTOTPSecretEngine totpEngine) {
this.totpEngine = totpEngine;
}
@Transactional
public EnrollmentResponse enroll(String subject) {
if (TotpEnrollment.findById(subject) != null) {
throw new ClientErrorException("TOTP is already enrolled", Response.Status.CONFLICT);
}
String vaultKey = "user-" + UUID.randomUUID();
CreateKeyParameters parameters = new CreateKeyParameters("LedgerLock", subject);
parameters.setExported(true);
parameters.setPeriod("30s");
parameters.setAlgorithm("SHA1");
parameters.setDigits(6);
parameters.setSkew(1);
parameters.setQrSize(240);
Optional<KeyDefinition> created = totpEngine.createKey(vaultKey, parameters);
KeyDefinition definition = created.orElseThrow(
() -> new IllegalStateException("Vault did not export the TOTP enrollment data"));
TotpEnrollment enrollment = new TotpEnrollment();
enrollment.subject = subject;
enrollment.vaultKey = vaultKey;
enrollment.createdAt = Instant.now();
try {
enrollment.persistAndFlush();
} catch (PersistenceException failure) {
deleteCompensatingKey(vaultKey);
throw new ClientErrorException("TOTP enrollment already exists", Response.Status.CONFLICT, failure);
}
return new EnrollmentResponse(
"data:image/png;base64," + definition.getBarcode(),
definition.getUrl());
}
public boolean validate(String subject, String code) {
TotpEnrollment enrollment = TotpEnrollment.findById(subject);
if (enrollment == null) {
return false;
}
try {
return totpEngine.validateCode(enrollment.vaultKey, code);
} catch (VaultClientException failure) {
if (failure.getStatus() == 400 && failure.hasErrorContaining("code already used")) {
return false;
}
throw failure;
}
}
private void deleteCompensatingKey(String vaultKey) {
try {
totpEngine.deleteKey(vaultKey);
} catch (RuntimeException cleanupFailure) {
LOG.warnf(cleanupFailure, "Could not remove orphaned Vault TOTP key %s", vaultKey);
}
}
public record EnrollmentResponse(String qrCodeDataUrl, String manualEntryUri) {
}
}The constructor new CreateKeyParameters("LedgerLock", subject) asks Vault to generate the seed. exported=true returns the QR code and otpauth:// URI once so the user can bind an authenticator app. The Vault TOTP API also supports imported seeds, different periods, eight-digit codes, SHA-256, and SHA-512. Six digits, a 30-second period, and SHA-1 work with most authenticator apps. SHA-1 is the HMAC algorithm used for TOTP compatibility here. We do not use it to hash a password.
skew=1 accepts a neighboring time step to handle normal clock drift. This helps users near a 30-second boundary, but it also increases the number of accepted codes. Keep clocks synchronized and only increase the window when you have a clear reason.
The database transaction cannot include Vault. If Vault creates a key and the database insert then loses a uniqueness race, the catch block deletes the orphaned key. There are still crash windows between those two systems. A production enrollment service needs reconciliation for orphaned keys and records, usually with an explicit PENDING state.
One Vault behavior is easy to miss. A wrong code normally returns false. A reused code makes patched Vault return HTTP 400 with code already used; wait until the next time period. The Quarkus wrapper turns that response into VaultClientException. Reuse is an authentication failure, so the service maps this specific response to false. Other Vault failures still escape because they are operational errors.
Exchange the code for higher assurance
The enrollment response contains the TOTP seed, whether represented as a QR image or a URI. It should be delivered once over TLS and never cached. The resource also requires an already authenticated user.
Create TotpResource.java:
package com.themainthread.ledgerlock;
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/totp")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("user")
public class TotpResource {
private final JsonWebToken accessToken;
private final TotpService totpService;
private final StepUpTokenService stepUpTokenService;
public TotpResource(JsonWebToken accessToken, TotpService totpService, StepUpTokenService stepUpTokenService) {
this.accessToken = accessToken;
this.totpService = totpService;
this.stepUpTokenService = stepUpTokenService;
}
@POST
@Path("/enrollment")
public Response enroll() {
TotpService.EnrollmentResponse enrollment = totpService.enroll(accessToken.getSubject());
CacheControl noStore = new CacheControl();
noStore.setNoStore(true);
noStore.setNoCache(true);
return Response.status(Response.Status.CREATED)
.cacheControl(noStore)
.header("Pragma", "no-cache")
.entity(enrollment)
.build();
}
@POST
@Path("/step-up")
public StepUpResponse stepUp(@Valid OtpRequest request) {
String subject = accessToken.getSubject();
if (!totpService.validate(subject, request.code())) {
throw new ClientErrorException("The TOTP code is invalid or was already used", Response.Status.UNAUTHORIZED);
}
return new StepUpResponse(stepUpTokenService.issueFor(subject), StepUpTokenService.LIFESPAN_SECONDS);
}
public record OtpRequest(
@NotBlank @Pattern(regexp = "[0-9]{6}", message = "must contain exactly six ASCII digits") String code) {
}
public record StepUpResponse(String token, long expiresInSeconds) {
}
}[0-9]{6} makes the ASCII input boundary explicit, and the resource does not trim the value. The 2025 Vault vulnerability made used-code cache entries bypassable by appending whitespace. The patched server fixes that flaw, and the resource rejects the malformed value before it crosses the network. Both controls are cheap.
Now create the token service:
package com.themainthread.ledgerlock;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class StepUpTokenService {
static final long LIFESPAN_SECONDS = 120;
public String issueFor(String subject) {
return Jwt.subject(subject)
.upn(subject)
.groups(Set.of("user", "payout:approve"))
.claim("amr", List.of("pwd", "otp"))
.claim("acr", "urn:ledgerlock:assurance:mfa")
.expiresAt(Instant.now().plusSeconds(LIFESPAN_SECONDS))
.sign();
}
}The token keeps the same subject and adds the narrow role needed for the payout. Its amr claim records that password and OTP methods were used. The acr value is private to LedgerLock and means the token met our local MFA policy. Other services must agree on that meaning before they trust it.
The token expires after two minutes, so the extra role is available only for a short time. This example allows more than one payout during that window. For one high-value transaction, bind the token to a payout ID or operation hash and consume its jti once.
Protect the business operation with normal RBAC
Create PayoutResource.java:
package com.themainthread.ledgerlock;
import java.math.BigDecimal;
import java.util.UUID;
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.annotation.security.RolesAllowed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/payouts")
@Produces(MediaType.APPLICATION_JSON)
public class PayoutResource {
private final JsonWebToken accessToken;
public PayoutResource(JsonWebToken accessToken) {
this.accessToken = accessToken;
}
@POST
@RolesAllowed("payout:approve")
public Response approve(@Valid PayoutRequest request) {
ApprovedPayout payout = new ApprovedPayout(
UUID.randomUUID(),
request.recipient(),
request.amount(),
accessToken.getSubject(),
"APPROVED");
return Response.status(Response.Status.ACCEPTED).entity(payout).build();
}
public record PayoutRequest(
@NotBlank String recipient,
@NotNull @DecimalMin("0.01") BigDecimal amount) {
}
public record ApprovedPayout(
UUID id,
String recipient,
BigDecimal amount,
String approvedBy,
String status) {
}
}The payout code knows nothing about Vault or six-digit codes. Quarkus verifies the JWT and enforces @RolesAllowed. TOTP raises the authentication level once, and the business endpoint only checks the resulting role.
Stand in for the first factor in local development
We still need a normal access token to start the flow. This development-only endpoint issues one with the user role:
package com.themainthread.ledgerlock;
import java.util.List;
import java.util.Set;
import io.quarkus.arc.profile.UnlessBuildProfile;
import io.smallrye.jwt.build.Jwt;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/dev/token")
@Produces(MediaType.APPLICATION_JSON)
@UnlessBuildProfile("prod")
public class DevTokenResource {
@GET
@Path("/{subject}")
public TokenResponse token(@PathParam("subject") String subject) {
String token = Jwt.subject(subject)
.upn(subject)
.groups(Set.of("user"))
.claim("amr", List.of("pwd"))
.sign();
return new TokenResponse(token);
}
public record TokenResponse(String token) {
}
}@UnlessBuildProfile("prod") removes the resource from a production build. It is a local replacement for the access token your identity provider would issue after the first-factor login. A production token endpoint with no login would bypass every other control in this example.
Run the full flow
Start the application with Podman available:
./mvnw quarkus:devQuarkus starts Vault 2.0.3, enables the TOTP engine, creates the H2 schema, and generates the dev JWT key pair. Check readiness:
curl -s http://localhost:8080/q/health/ready | jqThe response should include an UP check for Vault.
Get Alice’s first-factor token:
TOKEN=$(curl -s http://localhost:8080/dev/token/alice | jq -r .token)Enroll the authenticator:
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/totp/enrollment \
| tee enrollment.jsonThe response contains qrCodeDataUrl and manualEntryUri. Both contain the TOTP seed, so keep enrollment.json only long enough to bind the authenticator. To display the QR code, create a small local HTML file:
printf '<img src="%s">' "$(jq -r .qrCodeDataUrl enrollment.json)" > enrollment.htmlOpen enrollment.html in a browser and scan it. Then replace 123456 below with the current code:
STEP_TOKEN=$(curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"code":"123456"}' \
http://localhost:8080/api/totp/step-up \
| jq -r .token)The first token still cannot approve a payout:
curl -i -X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"recipient":"supplier-42","amount":1250.00}' \
http://localhost:8080/api/payoutsExpected status:
HTTP/1.1 403 ForbiddenUse the step-up token:
curl -s -X POST \
-H "Authorization: Bearer $STEP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"recipient":"supplier-42","amount":1250.00}' \
http://localhost:8080/api/payouts \
| jqYou should get an accepted payout:
{
"id": "a1e0d0d9-0f19-4da3-8d11-099aeb7314b1",
"recipient": "supplier-42",
"amount": 1250.00,
"approvedBy": "alice",
"status": "APPROVED"
}Send the same TOTP code to /api/totp/step-up again during its 30-second window. The API returns 401 Unauthorized. Add a trailing space and it returns 400 Bad Request before Vault receives it.
Prove replay handling in a test
The manual flow shows the user experience. The automated test asks Vault to generate the code for the enrolled key, then proves the security properties without waiting for somebody to type from a phone:
package com.themainthread.ledgerlock;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.startsWith;
import java.math.BigDecimal;
import java.util.Map;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.vault.VaultTOTPSecretEngine;
import jakarta.inject.Inject;
@QuarkusTest
class TotpStepUpTest {
@Inject
VaultTOTPSecretEngine totpEngine;
@Test
void aTotpCodeCreatesAShortLivedStepUpTokenAndCannotBeReplayed() {
String firstFactorToken = given()
.when().get("/dev/token/alice")
.then()
.statusCode(200)
.extract().path("token");
given()
.auth().oauth2(firstFactorToken)
.when().post("/api/totp/enrollment")
.then()
.statusCode(201)
.header("Cache-Control", startsWith("no-cache"))
.body("qrCodeDataUrl", startsWith("data:image/png;base64,"))
.body("manualEntryUri", startsWith("otpauth://totp/"));
String vaultKey = totpEngine.listKeys().getFirst();
String code = totpEngine.generateCode(vaultKey);
String stepUpToken = given()
.auth().oauth2(firstFactorToken)
.contentType("application/json")
.body(Map.of("code", code))
.when().post("/api/totp/step-up")
.then()
.statusCode(200)
.body("expiresInSeconds", equalTo(120))
.extract().path("token");
given()
.auth().oauth2(firstFactorToken)
.contentType("application/json")
.body(new PayoutResource.PayoutRequest("supplier-42", new BigDecimal("1250.00")))
.when().post("/api/payouts")
.then()
.statusCode(403);
given()
.auth().oauth2(stepUpToken)
.contentType("application/json")
.body(new PayoutResource.PayoutRequest("supplier-42", new BigDecimal("1250.00")))
.when().post("/api/payouts")
.then()
.statusCode(202)
.body("approvedBy", equalTo("alice"))
.body("status", equalTo("APPROVED"));
given()
.auth().oauth2(firstFactorToken)
.contentType("application/json")
.body(Map.of("code", code))
.when().post("/api/totp/step-up")
.then()
.statusCode(401);
given()
.auth().oauth2(firstFactorToken)
.contentType("application/json")
.body(Map.of("code", code + " "))
.when().post("/api/totp/step-up")
.then()
.statusCode(400);
}
}Run it:
./mvnw testThe test uses the same pinned Vault Dev Service as dev mode. The Vault version is part of the test fixture, so changing it runs the replay test again.
Before you use this in production
The example now has a clear two-factor flow. A production TOTP service still needs lifecycle and abuse controls that are outside this small application.
Let the identity provider own MFA when it can. If your OpenID Connect provider already enrolls authenticators and issues trustworthy amr and acr claims, consume those claims in Quarkus. An application-owned TOTP verifier makes sense for a dedicated authentication service, a controlled internal platform, or a legacy boundary where the central provider cannot express the required step-up flow.
Give the application a narrow Vault identity. Dev Services uses a root token because the container is disposable. Production should use Kubernetes auth, AppRole, or another machine authentication method with TLS and a policy limited to the application’s totp/keys/... and totp/code/... paths. The application validates codes; it should not have permission to generate user codes from GET /totp/code/:name.
Rate-limit failures by account and context. A six-digit code has a small online search space. NIST SP 800-63B-4 requires throttling for online guessing and treats 100 consecutive failures as an upper bound, not a target. Use a much lower risk-based threshold where your user population and recovery process allow it. Put the counter in shared storage, an API gateway, or the identity service so adding Quarkus replicas does not multiply the guess budget.
Build recovery and rotation deliberately. Users lose phones. They also replace them. Recovery codes need hashed storage, one-time consumption, monitoring, and a notification path. Rotation should require an existing factor or a verified recovery process. Keep enrollment states such as PENDING, ACTIVE, and REVOKED, and delete abandoned Vault keys after a bounded time.
Keep the QR response out of logs and caches. The QR image and otpauth:// URI both carry the seed. Cache-Control: no-store helps with HTTP caches, but your reverse proxy, tracing, body logging, browser history, and support tooling also need review. Never log KeyDefinition; its toString() includes the barcode and URI.
Decide what happens when Vault is down. The health check can mark the service unready, but that may remove every replica at once during a Vault outage. Existing step-up tokens can still pass local JWT verification until they expire. New enrollment and verification cannot. Choose that failure mode consciously, monitor it, and keep the Vault read timeout short.
TOTP is not phishing-resistant. Manually entered OTPs are replay-resistant when the verifier accepts each value once, but NIST does not consider them phishing-resistant. A fake login page can relay a current code. Passkeys and WebAuthn bind authentication to the verifier and are the stronger default for new public-facing systems. TOTP remains relevant for compatibility, controlled enterprise environments, and recovery paths. Treat it as one supported authentication level, not the final design for every system.
Keep the responsibilities separate
Vault owns the TOTP seed and the one-time validation rule. Quarkus owns the authenticated subject, enrollment state, and authorization decision. The six-digit code crosses that boundary once and becomes a short-lived claim that ordinary RBAC can enforce.
This is a better design than adding X-TOTP-Code to every protected request. The first-factor result is a signed token. The sensitive operation asks for fresh assurance. We test replay behavior against the real Vault runtime, and the business endpoint stays free of authentication code.


