Java Security Demystified: Building TOTP & TAN Tokens with Quarkus
A hands-on tutorial that shows how one-time passwords and transaction codes really work. Explained step by step in pure Java.
If you’re thinking “I’ll just hack together my own 2FA,” pause. That’s like deciding you’ll build your own jet engine because you once flew economy. Crypto is subtle. Mistakes are quiet and expensive. We’ll simulate how TOTP (time-based one-time passwords) and transaction TANs work, entirely for learning. Do not deploy this to production. If you need the real thing, use vetted standards and products, get security reviews, and threat-model properly. We’ll cite standards as we go.
You’ll finish with a runnable Quarkus app that:
Provisions a TOTP secret and renders a scannable QR code.
Verifies a 6-digit code from an authenticator app.
Demonstrates a “TAN-like” HMAC signature bound to transaction details (the idea behind PSD2 “dynamic linking”), purely to show theory. For production, study OCRA, FIDO2/WebAuthn, or vendor SDKs.
Big warning: This project is a learning aid. It intentionally skips critical controls like hardware-backed key storage, phishing resistance, device binding, replay protection, rate limiting, tamper-resistant UX, auditing, and more. Don’t ship this. Use standards and certified components.
Why I am writing this tutorial
You’ll understand the moving parts. TOTP is standardised (RFC 6238) and widely interoperable with authenticator apps. Knowing how it ticks helps with troubleshooting, compliance, and vendor evaluation.
Dynamic linking basics. High-risk transactions often require codes tied to the exact amount and payee. We’ll show the concept using an HMAC over a canonical string, and point you to OCRA/PSD2 for the proper way.
What you’ll build
A Quarkus application with REST endpoints to:
Register a user, generate a Base32 TOTP secret, and return an otpauth:// URI.
Render the provisioning QR as PNG using Quarkiverse QRCodeGen (Nayuki QR code generator under the hood).
Verify 6-digit TOTP codes compatible with Google Authenticator and others (default SHA-1, 30s, 6 digits per RFC 6238).
Create and verify a “TAN-like” HMAC code over
{txId|amount|currency|beneficiary}
to illustrate dynamic linking (not a standard; see OCRA).
Prerequisites
JDK 21 or 17
Maven 3.9+
Any authenticator app (Google Authenticator, Authy, etc.) implementing RFC 6238.
Bootstrap the project
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.acme.totp \
-DprojectArtifactId=quarkus-otp \
-DclassName="com.acme.totp.Hello" \
-Dpath="/hello" \
-Dextensions="rest-jackson,quarkus-qrcodegen,elytron-security-common"
cd quarkus-otp
And as usual, feel free to just grab the project from my Github repository and start from there.
Add Commons-Codec
Let’s add Apache Commons Codec for Base32 encoding.
<!-- Base32 for TOTP secrets -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.19.0</version>
</dependency>
Theory in 90 seconds
HOTP (RFC 4226): HMAC(secret, counter) → dynamic truncation → N digits. Event-based. (IETF)
TOTP (RFC 6238): HOTP where counter = floor((now) / 30s). Default SHA-1, 6 digits, 30-second steps. Apps scan an
otpauth://
URI via a QR code. (IETF)Dynamic linking / TANs: Calculate a code bound to specific transaction fields (amount, payee). A standard way is OCRA. We’ll just show the idea using HMAC over a canonical string. Don’t ship this. (IEFT)
Core implementation
We’ll keep storage in memory to focus on algorithms. In real systems, protect TOTP secrets in an HSM, Vault, or at least AES-GCM with key rotation.
DTOs
Carries the data a new user submits when enrolling for TOTP (username, password, issuer).
src/main/java/com/tmt/demo/api/dto/RegisterRequest.java
package com.acme.totp.dto;
public class RegisterRequest {
public String username; // demo only
public String password; // demo only
public String issuer; // shown in authenticator app label
}
Returns the generated TOTP provisioning details such as the otpauth URI, QR code URL, and a demo warning.
src/main/java/com/tmt/demo/api/dto/RegisterResponse.java
package com.acme.totp.dto;
public class RegisterResponse {
public String otpauthUri; // e.g. otpauth://totp/Issuer:alice?secret=...&issuer=Issuer...
public String qrUrl; // http link to PNG in this app
public String note; // loud warning that this is a demo
}
Contains the username and 6-digit code to check against the server’s TOTP validator.
src/main/java/com/tmt/demo/api/dto/TotpVerifyRequest.java
package com.acme.totp.dto;
public class TotpVerifyRequest {
public String username;
public String code; // 6 digits as string
}
Holds the transaction details (ID, amount, currency, beneficiary) used to build a canonical string for TAN signing.
src/main/java/com/tmt/demo/api/dto/TanChallengeRequest.java
package com.acme.totp.dto;
import java.math.BigDecimal;
public class TanChallengeRequest {
public String username;
public String txId;
public BigDecimal amount;
public String currency;
public String beneficiary;
}
Submits the canonical string and the TAN code the user typed so the server can verify transaction binding.
src/main/java/com/tmt/demo/api/dto/TanVerifyRequest.java
package com.acme.totp.dto;
public class TanVerifyRequest {
public String username;
public String canonical; // must match server-provided canonical string
public String tan; // user-provided TAN code (string)
}
Simulates a user’s device computing a TAN from a canonical string using their secret, purely for demo purposes.
src/main/java/com/tmt/demo/api/dto/TanSignRequest.java
package com.acme.totp.dto;
/** DEMO ONLY: used to simulate a user's device computing the TAN locally. */
public class TanSignRequest {
public String username; // whose device/secret to use (demo)
public String canonical; // exact canonical string from /tan/challenge
public Integer digits; // optional; default 8
}
In-memory “user” store
The UserStore
in this tutorial is a deliberately simple in-memory holder for user records.
src/main/java/com/tmt/demo/domain/UserStore.java
package com.acme.totp.domain;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class UserStore {
public static class User {
public final String username;
public final String bcryptHash; // demo only
public final String base32Secret; // TOTP seed in Base32
public User(String username, String bcryptHash, String base32Secret) {
this.username = username;
this.bcryptHash = bcryptHash;
this.base32Secret = base32Secret;
}
}
private static final Map<String, User> USERS = new ConcurrentHashMap<>();
public static void put(User u) {
USERS.put(u.username, u);
}
public static User get(String username) {
return USERS.get(username);
}
public static boolean exists(String username) {
return USERS.containsKey(username);
}
}
It wraps a
ConcurrentHashMap<String, User>
where the key is the username.Each
User
object stores three fields:username
– the login name.bcryptHash
– a password hash (demo only; in real apps you’d delegate authentication to Quarkus Security or an IdP).base32Secret
– the TOTP seed, encoded in Base32, used to generate and verify OTP/TAN codes.
Static helper methods (
put
,get
,exists
) make it easy for the REST endpoints to register new users and retrieve their data.Because it lives entirely in memory, data vanishes when the application restarts; there is no persistence, encryption, or access control.
TOTP implementation (RFC 6238)
The Totp
class is a utility that implements the TOTP algorithm (RFC 6238) in plain Java so you can see exactly how one-time codes are generated and verified.
src/main/java/com/tmt/demo/crypto/Totp.java
package com.tmt.demo.crypto;
import org.apache.commons.codec.binary.Base32;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
public final class Totp {
private Totp() {}
public static String generate(String base32Secret, long epochSeconds,
int digits, int periodSeconds, String hmacAlgo) {
long counter = Math.floorDiv(epochSeconds, periodSeconds);
return hotp(base32Secret, counter, digits, hmacAlgo);
}
public static boolean verify(String base32Secret, String code, int digits,
int periodSeconds, int window, String hmacAlgo,
long epochSeconds) {
// Accept codes in a small window to tolerate clock skew, e.g. [-1, 0, +1]
for (int i = -window; i <= window; i++) {
long when = epochSeconds + (long) i * periodSeconds;
if (generate(base32Secret, when, digits, periodSeconds, hmacAlgo).equals(code)) {
return true;
}
}
return false;
}
public static String hotp(String base32Secret, long counter, int digits, String hmacAlgo) {
byte[] key = new Base32().decode(base32Secret);
byte[] msg = ByteBuffer.allocate(8).putLong(counter).array();
byte[] mac = hmac(hmacAlgo, key, msg);
// Dynamic truncation (RFC 4226)
int offset = mac[mac.length - 1] & 0x0F;
int binary =
((mac[offset] & 0x7F) << 24) |
((mac[offset + 1] & 0xFF) << 16) |
((mac[offset + 2] & 0xFF) << 8) |
(mac[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, digits);
String s = Integer.toString(otp);
return "0".repeat(digits - s.length()) + s; // left-pad
}
private static byte[] hmac(String algo, byte[] key, byte[] msg) {
try {
Mac mac = Mac.getInstance(algo);
mac.init(new SecretKeySpec(key, algo));
return mac.doFinal(msg);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public static String otpauthUri(String issuer, String account, String base32Secret,
int digits, int periodSeconds, String algo) {
String label = url(issuer) + ":" + url(account);
return "otpauth://totp/" + label +
"?secret=" + base32Secret +
"&issuer=" + url(issuer) +
"&algorithm=" + url(algo) +
"&digits=" + digits +
"&period=" + periodSeconds;
}
private static String url(String s) {
return java.net.URLEncoder.encode(s, StandardCharsets.UTF_8);
}
public static long now() {
return Instant.now().getEpochSecond();
}
}
It converts a Base32-encoded secret into bytes and uses HMAC (SHA-1/256/512) as the core primitive.
generate()
computes a code for a given timestamp by turning the current UNIX time into a counter (epochSeconds / periodSeconds
).verify()
checks a user’s code against the expected code, allowing a small time window for clock drift.hotp()
contains the underlying HMAC-based one-time password logic from RFC 4226, including dynamic truncation.otpauthUri()
builds the provisioning URI that authenticator apps like Google Authenticator can import via QR code.now()
is a small helper that returns the current UNIX time in seconds.
TAN-style transaction signature (demo)
The TanSigner
class is a demo-only helper that illustrates how a transaction authentication number (TAN) can be bound to specific transaction details.
src/main/java/com/tmt/demo/crypto/TanSigner.java
package com.tmt.demo.crypto;
package com.acme.totp.crypto;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base32;
/**
* Demo "TAN" that HMACs a canonical string to illustrate dynamic linking.
* Not a standard. For real systems study OCRA (RFC 6287) and PSD2 SCA guidance.
*/
public final class TanSigner {
private TanSigner() {
}
public static String canonical(String txId, String amount, String currency, String beneficiary) {
// Stable order and separators; no whitespace; caller pre-normalizes amount
// format
return "id=" + txId + ";amt=" + amount + ";cur=" + currency + ";to=" + beneficiary;
}
public static String sign(String base32Secret, String canonical, int digits) {
byte[] key = new Base32().decode(base32Secret);
byte[] mac = hmac("HmacSHA256", key, canonical.getBytes(StandardCharsets.UTF_8));
// Reuse HOTP truncation idea for N digits
int offset = mac[mac.length - 1] & 0x0F;
int binary = ((mac[offset] & 0x7F) << 24) |
((mac[offset + 1] & 0xFF) << 16) |
((mac[offset + 2] & 0xFF) << 8) |
(mac[offset + 3] & 0xFF);
int code = binary % (int) Math.pow(10, digits);
String s = Integer.toString(code);
return "0".repeat(digits - s.length()) + s;
}
private static byte[] hmac(String algo, byte[] key, byte[] msg) {
try {
Mac mac = Mac.getInstance(algo);
mac.init(new SecretKeySpec(key, algo));
return mac.doFinal(msg);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
}
canonical()
builds a stable, deterministic string from fields like transaction ID, amount, currency, and beneficiary.sign()
takes the Base32-encoded secret and the canonical string, runs an HMAC (SHA-256), applies dynamic truncation, and formats the result into a fixed-length numeric TAN.Internally it reuses the same HMAC-and-truncate idea as HOTP/TOTP, but with transaction data instead of a time counter.
The output changes whenever any field in the canonical string changes, which demonstrates the idea of dynamic linking (a TAN that is valid only for a particular transaction).
In short, TanSigner
exists to show the conceptual math behind TANs, but real banking systems use standardised protocols like OCRA and much stronger device-binding and replay protections.
QR code endpoint with Quarkiverse QRCodeGen
The QrResource
class is a REST endpoint that generates and streams a PNG QR code for any input string, most importantly the otpauth://
URIs used by authenticator apps.
src/main/java/com/tmt/demo/api/QrResource.java
package com.tmt.demo.api;
package com.acme.totp.api;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import javax.imageio.ImageIO;
import io.nayuki.qrcodegen.QrCode;
import io.nayuki.qrcodegen.QrCode.Ecc;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
/**
* Streams a PNG QR code for a given data string.
* Quarkiverse QRCodeGen pulls in Nayuki's generator.
*/
@Path("/qr")
public class QrResource {
@GET
@Produces("image/png")
public Response png(@QueryParam("data") String data,
@QueryParam("size") @DefaultValue("256") int size) throws Exception {
if (data == null || data.isBlank()) {
return Response.status(400).entity("Missing ?data").build();
}
QrCode qr = QrCode.encodeText(data, Ecc.MEDIUM);
BufferedImage img = toImage(qr, size, size);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(img, "PNG", out);
return Response.ok(out.toByteArray()).build();
}
private static BufferedImage toImage(QrCode qr, int width, int height) {
int scale = Math.max(1, Math.min(width, height) / (qr.size + 2)); // 1 module border
int imgSize = (qr.size + 2) * scale;
BufferedImage img = new BufferedImage(imgSize, imgSize, BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < imgSize; y++) {
for (int x = 0; x < imgSize; x++) {
img.setRGB(x, y, 0xFFFFFF);
}
}
for (int y = 0; y < qr.size; y++) {
for (int x = 0; x < qr.size; x++) {
int color = qr.getModule(x, y) ? 0x000000 : 0xFFFFFF;
for (int dy = 0; dy < scale; dy++) {
for (int dx = 0; dx < scale; dx++) {
img.setRGB((x + 1) * scale + dx, (y + 1) * scale + dy, color);
}
}
}
}
return img;
}
}
It exposes
/qr?data=...
wheredata
is the text you want to encode.The Quarkiverse extension gives us the Nayuki QR library within Quarkus. We render a PNG suitable for scanning by authenticator apps. (Quarkus)
toImage()
converts the QR matrix into a black-and-whiteBufferedImage
with scaling and a quiet border.The image is written to a
ByteArrayOutputStream
as PNG and returned in the HTTP response.
REST API for register, TOTP verify, TAN demo
The OtpResource
class is the main REST controller that ties the whole demo together, exposing endpoints for user registration, TOTP verification, and TAN simulation.
src/main/java/com/tmt/demo/api/OtpResource.java
package com.acme.totp.api;
import java.math.BigDecimal;
import java.security.SecureRandom;
import org.apache.commons.codec.binary.Base32;
import com.acme.totp.crypto.TanSigner;
import com.acme.totp.crypto.Totp;
import com.acme.totp.domain.UserStore;
import com.acme.totp.dto.RegisterRequest;
import com.acme.totp.dto.RegisterResponse;
import com.acme.totp.dto.TanChallengeRequest;
import com.acme.totp.dto.TanSignRequest;
import com.acme.totp.dto.TanVerifyRequest;
import com.acme.totp.dto.TotpVerifyRequest;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.ws.rs.Consumes;
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")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OtpResource {
private static final SecureRandom RNG = new SecureRandom();
private static final int TOTP_DIGITS = 6;
private static final int TOTP_PERIOD = 30; // seconds
private static final int TOTP_WINDOW = 1; // tolerate +/- 30s
private static final String TOTP_HMAC = "HmacSHA1"; // default per RFC 6238
@POST
@Path("/register")
public Response register(RegisterRequest req) {
if (req == null || blank(req.username) || blank(req.password) || blank(req.issuer)) {
return Response.status(400).entity("username, password, issuer required").build();
}
if (UserStore.exists(req.username)) {
return Response.status(409).entity("username already registered").build();
}
// Generate 20 random bytes (160-bit), Base32 without padding
byte[] secret = new byte[20];
RNG.nextBytes(secret);
String base32 = new Base32().encodeAsString(secret).replace("=", "");
String bcrypt = BcryptUtil.bcryptHash(req.password); // demo only
UserStore.put(new UserStore.User(req.username, bcrypt, base32));
String otpauth = Totp.otpauthUri(req.issuer, req.username, base32,
TOTP_DIGITS, TOTP_PERIOD, "SHA1");
String qr = "/qr?data=" + url(otpauth);
RegisterResponse out = new RegisterResponse();
out.otpauthUri = otpauth;
out.qrUrl = qr;
out.note = "Demo only: your secret lives in memory and is not protected.";
return Response.ok(out).build();
}
@POST
@Path("/totp/verify")
public Response verifyTotp(TotpVerifyRequest req) {
var u = UserStore.get(req.username);
if (u == null)
return Response.status(404).entity("unknown user").build();
if (blank(req.code) || !req.code.matches("\\d{6}")) {
return Response.status(400).entity("6-digit code required").build();
}
boolean ok = Totp.verify(u.base32Secret, req.code, TOTP_DIGITS,
TOTP_PERIOD, TOTP_WINDOW, TOTP_HMAC, Totp.now());
return Response.ok("{\"valid\":" + ok + "}").build();
}
@POST
@Path("/tan/challenge")
public Response tanChallenge(TanChallengeRequest req) {
var u = UserStore.get(req.username);
if (u == null)
return Response.status(404).entity("unknown user").build();
if (blank(req.txId) || req.amount == null || blank(req.currency) || blank(req.beneficiary)) {
return Response.status(400).entity("txId, amount, currency, beneficiary required").build();
}
String canonical = TanSigner.canonical(req.txId, money(req.amount), req.currency, req.beneficiary);
// Return canonical string the user/device would sign. Do NOT reveal TAN in real
// life.
return Response.ok("{\"canonical\":\"" + escape(canonical) + "\"}").build();
}
/**
* DEMO ONLY: simulate the user's device computing a TAN from the shared secret.
* In production, this logic must live on a user-controlled device/app, not on
* the server.
*/
@POST
@Path("/tan/sign")
public Response tanSign(TanSignRequest req) {
var u = UserStore.get(req.username);
if (u == null)
return Response.status(404).entity("unknown user").build();
if (req.canonical == null || req.canonical.isBlank()) {
return Response.status(400).entity("canonical required").build();
}
int digits = (req.digits == null) ? 8 : req.digits.intValue();
String tan = TanSigner.sign(u.base32Secret, req.canonical, digits);
// Return just the TAN. Real systems never compute this on the server.
return Response.ok("{\"tan\":\"" + tan + "\"}").build();
}
@POST
@Path("/tan/verify")
public Response tanVerify(TanVerifyRequest req) {
var u = UserStore.get(req.username);
if (u == null)
return Response.status(404).entity("unknown user").build();
if (blank(req.canonical) || blank(req.tan)) {
return Response.status(400).entity("canonical and tan required").build();
}
// Simulate a user's authenticator computing the TAN on a separate device:
String expected = TanSigner.sign(u.base32Secret, req.canonical, 8);
boolean ok = expected.equals(req.tan);
return Response.ok("{\"valid\":" + ok + "}").build();
}
private static boolean blank(String s) {
return s == null || s.isBlank();
}
private static String url(String s) {
return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8);
}
private static String money(BigDecimal a) {
return a.setScale(2).toPlainString();
}
private static String escape(String s) {
// naive JSON string escape for demo; prefer a proper JSON serializer for DTOs
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
Key points:
/api/register
registers a user, generates a random Base32 secret, stores it inUserStore
, builds anotpauth://
URI, and returns both the URI and a QR code link for easy enrollment./api/totp/verify
accepts a username and 6-digit code, then checks it against the expected TOTP using theTotp
utility, allowing a small time window for drift./api/tan/challenge
creates a canonical string from transaction details (ID, amount, currency, beneficiary) that the client should sign./api/tan/sign
(demo-only) simulates the client device by computing the TAN on the server so you can test end-to-end without a real mobile app./api/tan/verify
compares a submitted TAN with the expected one fromTanSigner
to confirm the transaction details were correctly signed.
Run and verify
Start your applications in dev mode:
./mvnw quarkus:dev
Register and provision TOTP
The first step in using one-time passwords is provisioning. That’s a fancy way of saying: we need to agree on a shared secret with the user’s authenticator app. The server generates the secret, the app imports it, and from then on both sides can compute the same short-lived codes.
Here’s what happens when you call our /api/register
endpoint.
You send a registration request with a username, password, and an issuer name. The issuer is simply the label that will appear in your authenticator app alongside the account name.
curl -sS -X POST http://localhost:8080/api/register \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"secret","issuer":"TMT"}' | jq
The server generates a new TOTP secret. Under the hood, we create 20 random bytes, encode them in Base32 (because authenticator apps expect that), and store them in our in-memory UserStore
alongside the user’s bcrypt-hashed password.
An otpauth://
URI is built. This is a standard URI format understood by almost every authenticator app. It looks like this:
{
"otpauthUri": "otpauth://totp/TMT:alice?secret=WO3FLPEAPS6MZTFSSG46UEMWLLDXR2A6&issuer=TMT&algorithm=SHA1&digits=6&period=30",
"qrUrl": "/qr?data=otpauth%3A%2F%2Ftotp%2FTMT%3Aalice%3Fsecret%3DWO3FLPEAPS6MZTFSSG46UEMWLLDXR2A6%26issuer%3DTMT%26algorithm%3DSHA1%26digits%3D6%26period%3D30",
"note": "Demo only: your secret lives in memory and is not protected."
}
It contains the secret, the label, and parameters like algorithm, number of digits, and time step.
Open the QR in a browser:
http://localhost:8080/qr?data=otpauth%3A%2F%2Ftotp%2F...
Scan it with your authenticator app. Your authenticator app starts generating codes. From now on, every 30 seconds the app will display a new 6-digit code derived from the shared secret and the current time.
Verify a TOTP code
At this point, the user is provisioned. The app and the server now “speak the same language,” because they both know the secret. The next step is to verify codes. When the user logs in, they type the code from their phone, and the server recomputes it to check if it matches.
Assume your app shows 571643
right now:
Now comes the real test: can the server and the app agree on the same code at the same time?
curl -sS -X POST http://localhost:8080/api/totp/verify \
-H 'content-type: application/json' \
-d '{"username":"alice","code":"243315"}'%
That’s what happens when you call /api/totp/verify
.
The user enters the code from their app. On the login page or API request, they type the 6-digit number currently shown in Google Authenticator, Authy, or similar. These codes change every 30 seconds.
The server recomputes the expected code. Using the stored Base32 secret, the server runs the same algorithm (HMAC-SHA1 over a counter derived from the current Unix time). By default, the period is 30 seconds and the code length is 6 digits.
A small time window is allowed. Clocks aren’t perfect. To handle drift, the server doesn’t just check the current time slice but also one before and after (±30 seconds). This prevents false negatives if your phone’s clock is slightly off.
Codes are compared. If the user’s code matches any of the accepted slices, verification succeeds. Otherwise the request is rejected, and repeated failures should eventually lock the account.
If the code is correct for the current time slice, the server responds:
{"valid":true}
If your clocks are off by more than 30–60 seconds, verification will fail. That’s by design. Check NTP on your host.
Create a TAN challenge and verify
With login secured by TOTP, we can look at how banks and payment apps protect transactions. The idea is that you don’t just prove who you are; you also prove what you approve. That’s where TANs (Transaction Authentication Numbers) come in.
A transaction is prepared. Suppose Alice wants to transfer €123.45 to ACME GmbH. She sends these details to /api/tan/challenge
:
curl -sS -X POST http://localhost:8080/api/tan/challenge \
-H 'content-type: application/json' \
-d '{"username":"alice","txId":"T-1001","amount":123.45,"currency":"EUR","beneficiary":"ACME GmbH"}'
The server builds a canonical string. To avoid ambiguity, we serialize the transaction in a fixed order with no extra whitespace:
{"canonical":"id=T-1001;amt=123.45;cur=EUR;to=ACME GmbH"}
This is the data that will be signed. If you change the amount, currency, or payee, the string changes too.
The user’s device signs the string. In a real banking app, your phone or token would take this canonical string and compute an HMAC or signature using its secret key. In our demo, we simulate this step with a helper endpoint /api/tan/sign
:
curl -sS -X POST http://localhost:8080/api/tan/sign \
-H 'content-type: application/json' \
-d '{"username":"alice","canonical":"id=T-1001;amt=123.45;cur=EUR;to=ACME GmbH","digits":8}'
The result is a short numeric TAN such as:
{"tan":"31415926"}
The TAN is sent back for verification. Finally, Alice submits both the canonical string and the TAN to /api/tan/verify
:
curl -sS -X POST http://localhost:8080/api/tan/verify \
-H 'content-type: application/json' \
-d '{"username":"alice","canonical":"id=T-1001;amt=123.45;cur=EUR;to=ACME GmbH","tan":"31415926"}'
The server runs the same signing function internally and checks if the TAN matches. If it does:
{"valid":true}
Change the amount or payee and the same TAN will no longer validate, because the canonical string changes and the signature won’t match.
This is the essence of dynamic linking: the code is valid only for the exact transaction details you saw and approved. Real-world systems implement this using the OCRA standard, stronger device binding, and secure key storage, but the demo shows the principle clearly.
Production notes (what this demo intentionally skips)
Don’t roll your own: Use standards and audited libs. Prefer phishing-resistant methods like FIDO2/WebAuthn for step-up auth, with TOTP as a fallback.
Secret protection: Store TOTP seeds encrypted with keys in HSM or Vault, never as plaintext. Rotate keys. Enforce per-device binding.
Provisioning: The
otpauth://
URI is widely implemented but not an IETF standard. Treat QR codes as sensitive. Consider secure enrollment patterns.Drift and windows: Keep the window tight and your time sources synced. Throttle retries. Lock out after consecutive failures.
Dynamic linking: If you must do transaction signing, implement a standard like OCRA and follow PSD2 UX and integrity requirements.
Quarkus: Keep your platform pinned. Track maintenance/LTS releases to pick up CVE fixes. And get a supported version of Quarkus.
Not done yet? Want more?
SHA-256/512: RFC 6238 allows it. Ensure the authenticator supports it and reflect it in the otpauth URI.
Digits and period: 8 digits and/or 60-second steps increase entropy or usability trade-offs. Keep server and app in sync.
HOTP: Event-based counters instead of time. Useful for some hardware tokens.
Native image: For PNG generation, you may need extra configuration; test thoroughly before considering native.
Further reading
TOTP (RFC 6238) and HOTP (RFC 4226). (IETF)
OCRA (RFC 6287) for challenge/response and signatures. (IEFT)
otpauth URI format used by authenticator apps. (GitHub)
Quarkiverse QRCodeGen extension. (Quarkus)
Quarkus Maven tooling for version pinning and updates. (Quarkus)
Curiosity is good; shipping your own crypto is not.