← Back to blog

Send OTP Emails in Quarkus (2026) with a Plain HTTP API

Build signup/login OTP in Quarkus: SecureRandom, HMAC in ConcurrentHashMap/Redis/SQL, rate limits, MicroProfile REST Client or HttpClient POST /emails, Idempotency-Key—prefer HTTP over Mailer SMTP; contrast Spring Boot sibling; Rovela delivers only.

Updated November 4, 2026
Send OTP Emails in Quarkus (2026) with a Plain HTTP API

Why Quarkus + OTP codes (not Jakarta Mail)

Quarkus already gives signup and login a familiar shape: JAX-RS / Quarkus REST resources, CDI beans, SmallRye config, and a server-side store (ConcurrentHashMap, Redis via quarkus-redis-client, or Hibernate ORM). You do not need a Rovela Java SDK, an “OTP product” endpoint, or a starter that pretends the email API is an identity provider.

What you need is the recipe in Email verification codes: your app mints a six-digit code with SecureRandom, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text via a MicroProfile REST Client or Java HttpClient. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Your Quarkus service owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Quarkus focused (3.x / Java 17+, works the same from Kotlin): @Path resources for “send code” and “verify code,” a typed REST client (or HttpClient) for Rovela, an in-memory or Redis HMAC store, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. It is not a magic-link walkthrough—for those see Next.js, NestJS, Rails, and FastAPI. Sibling OTP posts: Spring Boot (closest JVM cousin), Express.js, Django, Go, Actix / Rust. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email. Generic Java / Kotlin transport (not OTP): Sending email in Java and Sending email in Kotlin—this post does not rehash Jakarta Mail SMTP dialers, MIME builders, or one-off “hello world” sends.

What you own vs what Rovela sends

Your Quarkus app owns:

  • SecureRandom → six digits, zero-padded (never Math.random() / java.util.Random)
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via Mac.getInstance("HmacSHA256")
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (ConcurrentHashMap, Redis, or SQL)
  • Constant-time compare on redeem (MessageDigest.isEqual); delete / invalidate on success
  • Identical public responses whether or not you want to reveal account existence (product choice)
  • Rate limits on /auth/otp/send and /auth/otp/verify

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in env—never commit it, never put it in a browser bundle, never log it)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Config: application.properties + env

# application.properties — values come from env / secrets in prod
rovela.api-key=${ROVELA_API_KEY}
rovela.from=Acme <security@mail.acme.com>
rovela.base-url=https://api.rovela.dev

otp.code-pepper=${CODE_PEPPER}
otp.ttl-minutes=10
otp.resend-cooldown-seconds=60
otp.max-attempts=5

# MicroProfile REST Client (optional path)
quarkus.rest-client.rovela.url=${rovela.base-url}
package com.example.otp;

import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.smallrye.config.WithName;

@ConfigMapping(prefix = "rovela")
public interface RovelaConfig {
  @WithName("api-key")
  String apiKey();

  String from();

  @WithName("base-url")
  @WithDefault("https://api.rovela.dev")
  String baseUrl();
}
package com.example.otp;

import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.smallrye.config.WithName;

@ConfigMapping(prefix = "otp")
public interface OtpConfig {
  @WithName("code-pepper")
  String codePepper();

  @WithName("ttl-minutes")
  @WithDefault("10")
  int ttlMinutes();

  @WithName("resend-cooldown-seconds")
  @WithDefault("60")
  int resendCooldownSeconds();

  @WithName("max-attempts")
  @WithDefault("5")
  int maxAttempts();
}

Wire ROVELA_API_KEY and CODE_PEPPER like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders. Never expose sending_access to a SPA, mobile app, or Quarkus Dev UI screenshot.

Prefer MicroProfile REST Client (or Java HttpClient)

Spring Boot’s sibling post uses RestClient / WebClient. In Quarkus the idiomatic twin is a typed MicroProfile REST Client; Java 11+ HttpClient is fine when you want zero extension surface.

package com.example.otp;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

@Path("/emails")
@RegisterRestClient(configKey = "rovela")
public interface RovelaMailClient {
  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(MediaType.APPLICATION_JSON)
  Map<?, ?> send(
      @HeaderParam("Authorization") String authorization,
      @HeaderParam("Idempotency-Key") String idempotencyKey,
      Map<String, Object> body);
}
package com.example.otp;

import jakarta.enterprise.context.ApplicationScoped;
import java.util.Map;
import org.eclipse.microprofile.rest.client.inject.RestClient;

@ApplicationScoped
public class RovelaMailService {
  private final RovelaMailClient client;
  private final RovelaConfig cfg;

  public RovelaMailService(@RestClient RovelaMailClient client, RovelaConfig cfg) {
    this.client = client;
    this.cfg = cfg;
  }

  public Map<?, ?> send(String to, String subject, String html, String text, String idempotencyKey) {
    return client.send(
        "Bearer " + cfg.apiKey(),
        idempotencyKey,
        Map.of(
            "from", cfg.from(),
            "to", to,
            "subject", subject,
            "html", html,
            "text", text));
  }
}

HttpClient alternative (no REST Client extension):

// sketch — same headers + JSON body
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create(cfg.baseUrl() + "/emails"))
    .header("Authorization", "Bearer " + cfg.apiKey())
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", idempotencyKey)
    .header("Accept", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(json))
    .timeout(Duration.ofSeconds(10))
    .build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofText());

Both paths call the same POST /emails contract. Prefer HTTP over Quarkus Mailer / Jakarta Mail SMTP for OTP: port 443 is open in containers, and Idempotency-Key is a first-class header.

Hash at rest: SecureRandom + HMAC, not bare SHA-256

Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server.

package com.example.otp;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class OtpCrypto {
  private static final SecureRandom RNG = new SecureRandom();

  private OtpCrypto() {}

  public static String normalizeEmail(String raw) {
    return raw == null ? "" : raw.trim().toLowerCase();
  }

  public static String mintCode() {
    // CSPRNG — Math.random() / java.util.Random are not for secrets
    int n = RNG.nextInt(1_000_000);
    return String.format("%06d", n);
  }

  public static String hashCode(String pepper, String email, String code) {
    try {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(pepper.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      byte[] out = mac.doFinal((email + ":" + code).getBytes(StandardCharsets.UTF_8));
      return HexFormat.of().formatHex(out);
    } catch (Exception e) {
      throw new IllegalStateException("HMAC failed", e);
    }
  }

  public static boolean safeEqualHex(String a, String b) {
    byte[] ab = HexFormat.of().parseHex(a);
    byte[] bb = HexFormat.of().parseHex(b);
    return MessageDigest.isEqual(ab, bb);
  }
}

Suggested row shape (one active code per email), matching the verification-code example:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

In-memory sketch for demos: a ConcurrentHashMap keyed by email works for a single JVM / Quarkus instance. Use Redis (ValueCommands) or Postgres when you run multiple replicas or native images behind a load balancer.

Service: send + verify (ConcurrentHashMap store)

package com.example.otp;

import java.time.Instant;

record OtpRecord(String codeHash, int attempts, Instant expiresAt, Instant sentAt) {
  OtpRecord withAttempts(int n) {
    return new OtpRecord(codeHash, n, expiresAt, sentAt);
  }
}
package com.example.otp;

import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@ApplicationScoped
public class OtpService {
  private static final String SAFE_SEND_MSG =
      "If that address can receive mail, a code is on its way.";

  private final OtpConfig cfg;
  private final RovelaMailService mail;
  private final ConcurrentHashMap<String, OtpRecord> store = new ConcurrentHashMap<>();

  public OtpService(OtpConfig cfg, RovelaMailService mail) {
    this.cfg = cfg;
    this.mail = mail;
  }

  public Map<String, Object> send(String rawEmail) {
    String email = OtpCrypto.normalizeEmail(rawEmail);
    Instant now = Instant.now();

    OtpRecord existing = store.get(email);
    if (existing != null
        && now.isBefore(existing.sentAt().plusSeconds(cfg.resendCooldownSeconds()))) {
      return Map.of("ok", false, "error", "too_soon");
    }

    String code = OtpCrypto.mintCode();
    String codeHash = OtpCrypto.hashCode(cfg.codePepper(), email, code);
    store.put(
        email,
        new OtpRecord(
            codeHash,
            0,
            now.plusSeconds(cfg.ttlMinutes() * 60L),
            now));

    // Stable per logical send: hash prefix. New code ⇒ new key.
    String idem = "otp-" + codeHash.substring(0, 32);
    int ttlMin = cfg.ttlMinutes();
    String html =
        "<p>Your verification code is</p>"
            + "<p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">"
            + code
            + "</p>"
            + "<p>It expires in "
            + ttlMin
            + " minutes. If you did not ask for it, ignore this email.</p>";
    String text =
        "Your verification code is "
            + code
            + ". It expires in "
            + ttlMin
            + " minutes. If you did not ask for it, ignore this email.";

    mail.send(email, "Your Acme code: " + code, html, text, idem);
    return Map.of("ok", true, "message", SAFE_SEND_MSG);
  }

  public Map<String, Object> verify(String rawEmail, String rawCode) {
    String email = OtpCrypto.normalizeEmail(rawEmail);
    String code = rawCode == null ? "" : rawCode.trim();
    if (code.length() != 6 || !code.chars().allMatch(Character::isDigit)) {
      return Map.of("ok", false, "error", "invalid");
    }

    OtpRecord rec = store.get(email);
    if (rec == null) {
      return Map.of("ok", false, "error", "invalid");
    }
    Instant now = Instant.now();
    if (now.isAfter(rec.expiresAt())) {
      store.remove(email);
      return Map.of("ok", false, "error", "expired");
    }
    if (rec.attempts() >= cfg.maxAttempts()) {
      return Map.of("ok", false, "error", "locked");
    }

    String actual = OtpCrypto.hashCode(cfg.codePepper(), email, code);
    if (!OtpCrypto.safeEqualHex(actual, rec.codeHash())) {
      store.put(email, rec.withAttempts(rec.attempts() + 1));
      return Map.of("ok", false, "error", "invalid");
    }

    store.remove(email);
    // issue session / JWT here (SmallRye JWT, Quarkus OIDC, etc.)
    return Map.of("ok", true);
  }
}

Putting the code in the subject helps lock-screen previews—fine for many products; skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails). Optional: use a dashboard template (template.id + variables) instead of inline HTML—still mutually exclusive with html/text, and your app still owns the OTP hash.

For Redis: SET otp:{email} {json} with TTL = expiry, plus SET otp:cooldown:{email} 1 EX 60. For Hibernate/SQL: upsert on email, bump attempts inside a transaction, DELETE on success.

Resources + rate limits

package com.example.otp;

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;
import java.util.Map;

@Path("/auth/otp")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OtpResource {
  private final OtpService svc;

  public OtpResource(OtpService svc) {
    this.svc = svc;
  }

  public record SendBody(String email) {}
  public record VerifyBody(String email, String code) {}

  @POST
  @Path("/send")
  public Response send(SendBody body) {
    if (body == null || body.email() == null || body.email().isBlank()) {
      return Response.status(Response.Status.BAD_REQUEST)
          .entity(Map.of("error", "invalid_email"))
          .build();
    }
    try {
      Map<String, Object> out = svc.send(body.email());
      if (Boolean.FALSE.equals(out.get("ok"))) {
        if ("too_soon".equals(out.get("error"))) {
          return Response.status(429).entity(Map.of("error", "too_soon")).build();
        }
      }
      return Response.ok(Map.of("message", out.get("message"))).build();
    } catch (Exception e) {
      return Response.status(Response.Status.BAD_GATEWAY)
          .entity(Map.of("error", "send_failed"))
          .build();
    }
  }

  @POST
  @Path("/verify")
  public Response verify(VerifyBody body) {
    Map<String, Object> out = svc.verify(body.email(), body.code());
    if (Boolean.FALSE.equals(out.get("ok"))) {
      int status =
          "locked".equals(out.get("error")) || "too_soon".equals(out.get("error")) ? 429 : 400;
      return Response.status(status).entity(Map.of("error", out.get("error"))).build();
    }
    return Response.ok(Map.of("ok", true)).build();
  }
}

Add a Bucket4j / Vert.x rate-limiter filter (or put Cloudflare / nginx in front) for per-IP limits on /auth/otp/**. Per-email cooldown inside the service and per-IP limits solve different problems—use both. Hardening checklist: Email OTP security.

Handle send failures the way the docs describe: 400 recipient is suppressed: … → ask for another address; domain-not-verified → alert yourself; 429 → honour Retry-After and keep the same Idempotency-Key (verification codes).

Idempotency-Key: retries without double codes

Workers, reverse proxies, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.

  • Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
  • Derive the key from the code hash (otp- + first 32 hex chars) so one logical mint maps to one queued message.
  • A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-quarkus-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

Quarkus vs Spring Boot for OTP (same API, different wiring)

Quarkus (this post)Spring Boot (sibling)
HTTP surfaceJAX-RS @Path resource@RestController
Config@ConfigMapping / application.properties@ConfigurationProperties
Outbound HTTPMicroProfile REST Client or HttpClientRestClient / WebClient
DICDI @ApplicationScopedSpring @Service / @Component
Crypto / storeSame: SecureRandom + HMAC + map/Redis/SQLSame
Rovela roleDelivery only via POST /emailsDelivery only via POST /emails

Pick the stack you already run. Do not invent a second OTP product layer on top of Rovela—both posts call the identical send endpoint with Idempotency-Key.

Why not Quarkus Mailer / Jakarta Mail SMTP for OTP

REST Client / HttpClient → POST /emails (this post)Quarkus Mailer / Jakarta Mail
TransportHTTPS 443465 / 587 (25 often blocked)
IdempotencyIdempotency-Key headerApp must dedupe
FeedbackMessage id + webhooksBounce DSNs later
Containers / K8s / nativePort 443 always openSMTP often restricted
OTP fitJSON html + textHand-built MIME

Use SMTP when you already depend on it. For new signup/login digits, call the HTTP API. Broader Java transport patterns live in Sending email in Java.

Deliverability notes for auth digits (not magic URLs)

OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.

If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.

OTP codes vs magic links in Quarkus

Six-digit OTP (this post)Magic link
User actionType digitsClick URL
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
Quarkus surfacePOST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
Click trackingOptional concernPrefer off on the security sending domain

Both call the same POST /emails. Keep separate stores—do not reuse an OTP record as a magic-link token.

Checklist before you ship

  1. SecureRandom + HMAC pepper; never store raw codes; never use Math.random() for OTP.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit.
  3. Upsert / overwrite invalidates the previous code; verify deletes on success.
  4. html and text (or a template); optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server / Quarkus config—never in frontend.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Prefer HTTP API over Quarkus Mailer SMTP for OTP; verified from-domain; spam checklist if inbox placement slips.
  9. Multi-replica: move the map to Redis/SQL before you scale past one pod.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Framework siblings: Spring Boot OTP · Express OTP · Django OTP · Go OTP · Actix OTP · magic-link Next.js / NestJS / Rails / FastAPI.
  6. Java / Kotlin transport only: Sending email in Java · Sending email in Kotlin.

Bottom line: put signup/login OTP in Quarkus REST resources that mint SecureRandom codes, HMAC them at rest (ConcurrentHashMap, Redis, or SQL), rate-limit send/verify, and call one MicroProfile REST Client or HttpClient POST to Rovela with an Idempotency-Key—prefer that over Quarkus Mailer SMTP, contrast the wiring with the Spring Boot sibling when you choose a JVM stack, and never pretend the email API is your auth server.