← Back to blog

Send OTP Emails in Ktor / Kotlin (2026) with a Plain HTTP API

Build signup/login OTP in Ktor: SecureRandom, HMAC in ConcurrentHashMap/Redis/SQL, rate limits, HttpClient POST /emails with Idempotency-Key—prefer HTTPS over SMTP; contrast Quarkus and Spring Boot siblings; Rovela delivers only.

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

Why Ktor + OTP codes (not Jakarta Mail)

Ktor already gives signup and login a clear shape: routing in an Application module, typed receive/respond, plugins for rate limits, and a server-side store (ConcurrentHashMap, Redis, or SQL). You do not need a Rovela Kotlin SDK, an “OTP product” endpoint, or a plugin 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 Ktor 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 Ktor service owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Ktor-server focused (Ktor 2.x / 3.x + Kotlin coroutines): routes for “send code” and “verify code,” a thin Rovela client with HttpClient + Idempotency-Key, and an in-memory or Redis HMAC store 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 and Quarkus (closest JVM cousins), Express.js, Go, Actix / Rust. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email. Generic Kotlin transport (HttpURLConnection / OkHttp / Ktor client hello-world, batches, webhooks)—not OTP ownership—lives in Sending email in Kotlin; this post does not rehash one-off welcome sends or webhook signature plumbing.

What you own vs what Rovela sends

Your Ktor app owns:

  • SecureRandom → six digits, zero-padded (never kotlin.random.Random / Math.random() for secrets)
  • 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/WASM 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: env / application.conf (never ship keys to clients)

# application.conf — override secrets from the environment in prod
rovela {
  apiKey = ${?ROVELA_API_KEY}
  from = "Acme <security@mail.acme.com>"
  baseUrl = "https://api.rovela.dev"
}

otp {
  codePepper = ${?CODE_PEPPER}
  ttlMinutes = 10
  resendCooldownSeconds = 60
  maxAttempts = 5
}
data class RovelaConfig(
    val apiKey: String,
    val from: String,
    val baseUrl: String = "https://api.rovela.dev",
)

data class OtpConfig(
    val codePepper: String,
    val ttlMinutes: Int = 10,
    val resendCooldownSeconds: Int = 60,
    val maxAttempts: Int = 5,
)

fun Application.loadRovelaConfig(): RovelaConfig {
    val cfg = environment.config
    return RovelaConfig(
        apiKey = cfg.property("rovela.apiKey").getString(),
        from = cfg.property("rovela.from").getString(),
        baseUrl = cfg.propertyOrNull("rovela.baseUrl")?.getString()
            ?: "https://api.rovela.dev",
    )
}

fun Application.loadOtpConfig(): OtpConfig {
    val cfg = environment.config
    return OtpConfig(
        codePepper = cfg.property("otp.codePepper").getString(),
        ttlMinutes = cfg.propertyOrNull("otp.ttlMinutes")?.getString()?.toInt() ?: 10,
        resendCooldownSeconds =
            cfg.propertyOrNull("otp.resendCooldownSeconds")?.getString()?.toInt() ?: 60,
        maxAttempts = cfg.propertyOrNull("otp.maxAttempts")?.getString()?.toInt() ?: 5,
    )
}

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 shared CI log.

Prefer Ktor HttpClient (+ Idempotency-Key)

Spring Boot’s sibling uses RestClient / WebClient; Quarkus uses a MicroProfile REST Client or Java HttpClient. In Ktor the idiomatic twin is the coroutine HttpClient (CIO or OkHttp engine) with ContentNegotiation.

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class RovelaEmailBody(
    val from: String,
    val to: String,
    val subject: String,
    val html: String,
    val text: String,
)

class RovelaMailClient(
    private val cfg: RovelaConfig,
    private val http: HttpClient = HttpClient(CIO) {
        install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    },
) {
    suspend fun send(
        to: String,
        subject: String,
        html: String,
        text: String,
        idempotencyKey: String,
    ): String {
        val response = http.post("${cfg.baseUrl}/emails") {
            header(HttpHeaders.Authorization, "Bearer ${cfg.apiKey}")
            header("Idempotency-Key", idempotencyKey)
            header(HttpHeaders.Accept, "application/json")
            contentType(ContentType.Application.Json)
            setBody(
                RovelaEmailBody(
                    from = cfg.from,
                    to = to,
                    subject = subject,
                    html = html,
                    text = text,
                ),
            )
        }
        val body = response.bodyAsText()
        check(response.status.isSuccess()) {
            "Rovela ${response.status.value}: $body"
        }
        return body // {"object":"email","id":"..."}
    }
}

This calls the same POST /emails contract as every other framework sibling. Prefer HTTPS over raw SMTP / Jakarta Mail 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.

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

object OtpCrypto {
    private val rng = SecureRandom()

    fun normalizeEmail(raw: String?): String =
        raw?.trim()?.lowercase().orEmpty()

    fun mintCode(): String {
        // CSPRNG — kotlin.random.Random / Math.random() are not for secrets
        val n = rng.nextInt(1_000_000)
        return "%06d".format(n)
    }

    fun hashCode(pepper: String, email: String, code: String): String {
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(pepper.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
        val out = mac.doFinal("$email:$code".toByteArray(StandardCharsets.UTF_8))
        return HexFormat.of().formatHex(out)
    }

    fun safeEqualHex(a: String, b: String): Boolean {
        val ab = HexFormat.of().parseHex(a)
        val 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 Ktor instance. Use Redis or Postgres when you run multiple replicas behind a load balancer.

Service: send + verify (ConcurrentHashMap store)

import java.time.Instant
import java.util.concurrent.ConcurrentHashMap

data class OtpRecord(
    val codeHash: String,
    val attempts: Int,
    val expiresAt: Instant,
    val sentAt: Instant,
)

class OtpService(
    private val cfg: OtpConfig,
    private val mail: RovelaMailClient,
) {
    private val store = ConcurrentHashMap<String, OtpRecord>()
    private val safeSendMsg = "If that address can receive mail, a code is on its way."

    suspend fun send(rawEmail: String): Map<String, Any?> {
        val email = OtpCrypto.normalizeEmail(rawEmail)
        val now = Instant.now()

        val existing = store[email]
        if (existing != null &&
            now.isBefore(existing.sentAt.plusSeconds(cfg.resendCooldownSeconds.toLong()))
        ) {
            return mapOf("ok" to false, "error" to "too_soon")
        }

        val code = OtpCrypto.mintCode()
        val codeHash = OtpCrypto.hashCode(cfg.codePepper, email, code)
        store[email] = OtpRecord(
            codeHash = codeHash,
            attempts = 0,
            expiresAt = now.plusSeconds(cfg.ttlMinutes * 60L),
            sentAt = now,
        )

        // Stable per logical send: hash prefix. New code ⇒ new key.
        val idem = "otp-" + codeHash.take(32)
        val ttlMin = cfg.ttlMinutes
        val 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>
            """.trimIndent()
        val 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 mapOf("ok" to true, "message" to safeSendMsg)
    }

    fun verify(rawEmail: String, rawCode: String?): Map<String, Any?> {
        val email = OtpCrypto.normalizeEmail(rawEmail)
        val code = rawCode?.trim().orEmpty()
        if (code.length != 6 || !code.all { it.isDigit() }) {
            return mapOf("ok" to false, "error" to "invalid")
        }

        val rec = store[email] ?: return mapOf("ok" to false, "error" to "invalid")
        val now = Instant.now()
        if (now.isAfter(rec.expiresAt)) {
            store.remove(email)
            return mapOf("ok" to false, "error" to "expired")
        }
        if (rec.attempts >= cfg.maxAttempts) {
            return mapOf("ok" to false, "error" to "locked")
        }

        val actual = OtpCrypto.hashCode(cfg.codePepper, email, code)
        if (!OtpCrypto.safeEqualHex(actual, rec.codeHash)) {
            store[email] = rec.copy(attempts = rec.attempts + 1)
            return mapOf("ok" to false, "error" to "invalid")
        }

        store.remove(email)
        // issue session / JWT here (ktor-server-auth-jwt, etc.)
        return mapOf("ok" to 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 SQL: upsert on email, bump attempts inside a transaction, DELETE on success.

Routing in the application module + rate limits

import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable

@Serializable
data class SendBody(val email: String)

@Serializable
data class VerifyBody(val email: String, val code: String)

fun Application.module() {
    val rovelaCfg = loadRovelaConfig()
    val otpCfg = loadOtpConfig()
    val mail = RovelaMailClient(rovelaCfg)
    val otp = OtpService(otpCfg, mail)

    install(ContentNegotiation) { json() }
    install(StatusPages) {
        exception<IllegalStateException> { call, _ ->
            call.respond(HttpStatusCode.BadGateway, mapOf("error" to "send_failed"))
        }
    }

    // Add RateLimit / custom plugin / reverse-proxy limits on /auth/otp/**
    routing {
        route("/auth/otp") {
            post("/send") {
                val body = call.receive<SendBody>()
                if (body.email.isBlank()) {
                    call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid_email"))
                    return@post
                }
                val out = otp.send(body.email)
                if (out["ok"] == false && out["error"] == "too_soon") {
                    call.respond(HttpStatusCode.TooManyRequests, mapOf("error" to "too_soon"))
                    return@post
                }
                call.respond(mapOf("message" to out["message"]))
            }

            post("/verify") {
                val body = call.receive<VerifyBody>()
                val out = otp.verify(body.email, body.code)
                if (out["ok"] == false) {
                    val status =
                        if (out["error"] == "locked" || out["error"] == "too_soon") {
                            HttpStatusCode.TooManyRequests
                        } else {
                            HttpStatusCode.BadRequest
                        }
                    call.respond(status, mapOf("error" to out["error"]))
                    return@post
                }
                call.respond(mapOf("ok" to true))
            }
        }
    }
}

Per-email cooldown inside the service and per-IP limits at the edge 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-ktor-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."
  }'

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

Ktor (this post)Quarkus (sibling)Spring Boot (sibling)
HTTP surfacerouting { post(...) } in Application.module()JAX-RS @Path resource@RestController
ConfigHOCON / env@ConfigMapping / application.properties@ConfigurationProperties
Outbound HTTPKtor HttpClientMicroProfile REST Client or HttpClientRestClient / WebClient
ConcurrencyCoroutines / suspendCDI + Vert.x / blockingSpring MVC or WebFlux
Crypto / storeSame: SecureRandom + HMAC + map/Redis/SQLSameSame
Rovela roleDelivery only via POST /emailsDelivery onlyDelivery only

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

Why not SMTP / Jakarta Mail for OTP

Ktor HttpClient → POST /emails (this post)SMTP / Jakarta Mail
TransportHTTPS 443465 / 587 (25 often blocked)
IdempotencyIdempotency-Key headerApp must dedupe
FeedbackMessage id + webhooksBounce DSNs later
Containers / K8sPort 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 Kotlin transport patterns live in Sending email in Kotlin.

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 Ktor

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
Ktor 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 non-crypto PRNGs 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 / HOCON env—never in frontend.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Prefer Ktor HttpClient over 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 · Quarkus OTP · Express OTP · Go OTP · Actix OTP · magic-link Next.js / NestJS / Rails / FastAPI.
  6. Kotlin transport only: Sending email in Kotlin.

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