← Back to blog

Send OTP Emails in Axum / Rust (2026) with a Plain HTTP API

Build signup/login OTP in Axum on Tokio: OsRng, HMAC in DashMap/Redis/SQL, Tower rate limits, reqwest POST /emails with Idempotency-Key—contrast Actix extractors vs services; prefer HTTP over lettre; Rovela delivers only.

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

Why Axum + OTP codes (not a magic-link SDK)

Axum (Tokio) already gives signup and login a clear shape: Router routes, typed extractors (State, Json, Extension), Tower middleware for rate limits, and a shared store behind Arc. You do not need a Rovela Rust SDK, an "OTP product" endpoint, or middleware 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 rand::rngs::OsRng (or getrandom), stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text via reqwest. 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 Axum service owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Axum focused (Axum 0.7+ on Tokio): handlers for "send code" and "verify code," State<AppState> for the OTP service + mail client, Tower layers for per-IP limits, 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, and FastAPI. Sibling OTP posts: Actix Web (closest Rust twin—routing/extractors vs Actix services), Go, Spring Boot, Ktor. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email. Generic Rust transport (reqwest batches, lettre SMTP, Axum wiring, webhooks)—not OTP ownership—lives in Sending email in Rust; this post does not rehash semaphore fan-out or lettre relays.

What you own vs what Rovela sends

Your Axum service owns:

  • OS CSPRNG → six digits, zero-padded (never a non-crypto PRNG for secrets)
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via the hmac + sha2 crates
  • 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 (DashMap, Redis, or SQL)
  • Constant-time compare on redeem (subtle::ConstantTimeEq); 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 (Tower / governor / reverse proxy)

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 WASM/frontend bundle, never log it)
  • Queueing, signing, retries, bounce/complaint handling

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

Env and a tiny Rovela client (reqwest + Idempotency-Key)

# Cargo.toml (excerpt)
[dependencies]
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
subtle = "2"
dashmap = "6"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
thiserror = "1"
// src/config.rs
use std::env;
use std::time::Duration;

#[derive(Clone)]
pub struct Config {
    pub api_key: String,
    pub code_pepper: Vec<u8>,
    pub from: String,
    pub ttl: Duration,
    pub resend_cooldown: Duration,
    pub max_attempts: u32,
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            api_key: must_env("ROVELA_API_KEY"), // sending_access
            code_pepper: must_env("CODE_PEPPER").into_bytes(),
            from: env::var("OTP_FROM")
                .unwrap_or_else(|_| "Acme <security@mail.acme.com>".into()),
            ttl: Duration::from_secs(
                env::var("OTP_TTL_MINUTES").ok().and_then(|v| v.parse().ok()).unwrap_or(10) * 60,
            ),
            resend_cooldown: Duration::from_secs(
                env::var("OTP_RESEND_COOLDOWN_SEC")
                    .ok()
                    .and_then(|v| v.parse().ok())
                    .unwrap_or(60),
            ),
            max_attempts: env::var("OTP_MAX_ATTEMPTS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(5),
        }
    }
}

fn must_env(k: &str) -> String {
    env::var(k).unwrap_or_else(|_| panic!("missing env {k}"))
}
// src/rovela.rs
use reqwest::Client;
use serde_json::{json, Value};
use thiserror::Error;

#[derive(Clone)]
pub struct MailClient {
    http: Client,
    api_key: String,
    from: String,
}

pub struct SendInput {
    pub to: String,
    pub subject: String,
    pub html: String,
    pub text: String,
    pub idempotency_key: String,
}

#[derive(Debug, Error)]
pub enum MailError {
    #[error("transport: {0}")]
    Transport(#[from] reqwest::Error),
    #[error("rovela send failed: {0} {1}")]
    Status(u16, String),
}

impl MailClient {
    pub fn new(api_key: String, from: String) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(15))
                .build()
                .expect("reqwest client"),
            api_key,
            from,
        }
    }

    pub async fn send(&self, in_: SendInput) -> Result<Value, MailError> {
        let body = json!({
            "from": self.from,
            "to": in_.to,
            "subject": in_.subject,
            "html": in_.html,
            "text": in_.text,
        });
        let res = self
            .http
            .post("https://api.rovela.dev/emails")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Idempotency-Key", in_.idempotency_key)
            .header("Accept", "application/json")
            .json(&body)
            .send()
            .await?;
        let status = res.status().as_u16();
        let text = res.text().await.unwrap_or_default();
        if !(200..300).contains(&status) {
            return Err(MailError::Status(status, text));
        }
        Ok(serde_json::from_str(&text).unwrap_or(json!({})))
    }
}

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. Keep the key out of anything you accidentally serialize into JSON responses, and never expose it through a public handler or Debug dump of AppState.

Hash at rest: OsRng + 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.

// src/crypto.rs
use hmac::{Hmac, Mac};
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

pub fn normalize_email(raw: &str) -> String {
    raw.trim().to_ascii_lowercase()
}

pub fn mint_code() -> String {
    // CSPRNG — ThreadRng / small PRNGs are not for secrets
    let n = OsRng.next_u32() % 1_000_000;
    format!("{n:06}")
}

pub fn hash_code(pepper: &[u8], email: &str, code: &str) -> String {
    let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC key");
    mac.update(format!("{email}:{code}").as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

pub fn safe_equal_hex(a: &str, b: &str) -> bool {
    let (Ok(ab), Ok(bb)) = (hex::decode(a), hex::decode(b)) else {
        return false;
    };
    if ab.len() != bb.len() {
        return false;
    }
    ab.ct_eq(&bb).into()
}

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 DashMap<String, Record> keyed by email works for a single process. Use Redis or Postgres when you run multiple replicas.

Service: send + verify (DashMap store)

// src/service.rs
use crate::config::Config;
use crate::crypto::{hash_code, mint_code, normalize_email, safe_equal_hex};
use crate::rovela::{MailClient, SendInput};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use std::sync::Arc;

#[derive(Clone)]
struct Record {
    code_hash: String,
    attempts: u32,
    expires_at: DateTime<Utc>,
    sent_at: DateTime<Utc>,
}

#[derive(Clone)]
pub struct OtpService {
    cfg: Config,
    mail: MailClient,
    store: Arc<DashMap<String, Record>>,
}

const SAFE_SEND_MSG: &str = "If that address can receive mail, a code is on its way.";

impl OtpService {
    pub fn new(cfg: Config, mail: MailClient) -> Self {
        Self {
            cfg,
            mail,
            store: Arc::new(DashMap::new()),
        }
    }

    pub async fn send(&self, raw_email: &str) -> Result<serde_json::Value, String> {
        let email = normalize_email(raw_email);
        let now = Utc::now();

        if let Some(rec) = self.store.get(&email) {
            if now.signed_duration_since(rec.sent_at)
                < chrono::Duration::from_std(self.cfg.resend_cooldown).unwrap()
            {
                return Ok(serde_json::json!({"ok": false, "error": "too_soon"}));
            }
        }

        let code = mint_code();
        let code_hash = hash_code(&self.cfg.code_pepper, &email, &code);
        self.store.insert(
            email.clone(),
            Record {
                code_hash: code_hash.clone(),
                attempts: 0,
                expires_at: now + chrono::Duration::from_std(self.cfg.ttl).unwrap(),
                sent_at: now,
            },
        );

        // Stable per logical send: hash prefix. New code ⇒ new key.
        let idem = format!("otp-{}", &code_hash[..32]);
        let ttl_min = self.cfg.ttl.as_secs() / 60;
        self.mail
            .send(SendInput {
                to: email,
                subject: format!("Your Acme code: {code}"),
                html: format!(
                    "<p>Your verification code is</p>\
                     <p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">{code}</p>\
                     <p>It expires in {ttl_min} minutes. If you did not ask for it, ignore this email.</p>"
                ),
                text: format!(
                    "Your verification code is {code}. It expires in {ttl_min} minutes. \
                     If you did not ask for it, ignore this email."
                ),
                idempotency_key: idem,
            })
            .await
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({"ok": true, "message": SAFE_SEND_MSG}))
    }

    pub fn verify(&self, raw_email: &str, raw_code: &str) -> serde_json::Value {
        let email = normalize_email(raw_email);
        let code = raw_code.trim();
        if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        let Some(mut entry) = self.store.get_mut(&email) else {
            return serde_json::json!({"ok": false, "error": "invalid"});
        };
        let now = Utc::now();
        if now > entry.expires_at {
            drop(entry);
            self.store.remove(&email);
            return serde_json::json!({"ok": false, "error": "expired"});
        }
        if entry.attempts >= self.cfg.max_attempts {
            return serde_json::json!({"ok": false, "error": "locked"});
        }

        let candidate = hash_code(&self.cfg.code_pepper, &email, code);
        if !safe_equal_hex(&candidate, &entry.code_hash) {
            entry.attempts += 1;
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        drop(entry);
        self.store.remove(&email);
        // issue session / JWT here
        serde_json::json!({"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).

For Redis: SET otp:{email} {json} with TTL = expiry, plus SET otp:cooldown:{email} 1 EX 60. For SQL: INSERT … ON CONFLICT (email) DO UPDATE, bump attempts inside a transaction, DELETE on success.

Handlers: Axum extractors + Tower notes

Axum's ergonomics differ from Actix: you compose a Router, share state with .with_state(...), and extract State<T> + Json<T> in free functions. Tower layers wrap the whole router (or a nested route group) instead of Actix-style middleware services on each App.

// src/handlers.rs
use crate::service::OtpService;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Clone)]
pub struct AppState {
    pub otp: OtpService,
}

#[derive(Deserialize)]
pub struct SendBody {
    email: String,
}

#[derive(Deserialize)]
pub struct VerifyBody {
    email: String,
    code: String,
}

pub async fn send_otp(
    State(state): State<Arc<AppState>>,
    Json(body): Json<SendBody>,
) -> impl IntoResponse {
    if body.email.trim().is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "invalid_email"})),
        )
            .into_response();
    }
    match state.otp.send(&body.email).await {
        Ok(out)
            if out.get("ok") == Some(&serde_json::Value::Bool(false))
                && out.get("error") == Some(&serde_json::Value::String("too_soon".into())) =>
        {
            (
                StatusCode::TOO_MANY_REQUESTS,
                Json(serde_json::json!({"error": "too_soon"})),
            )
                .into_response()
        }
        Ok(out) => (
            StatusCode::OK,
            Json(serde_json::json!({"message": out["message"]})),
        )
            .into_response(),
        Err(_) => (
            StatusCode::BAD_GATEWAY,
            Json(serde_json::json!({"error": "send_failed"})),
        )
            .into_response(),
    }
}

pub async fn verify_otp(
    State(state): State<Arc<AppState>>,
    Json(body): Json<VerifyBody>,
) -> impl IntoResponse {
    let out = state.otp.verify(&body.email, &body.code);
    if out.get("ok") == Some(&serde_json::Value::Bool(true)) {
        return (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response();
    }
    let err = out
        .get("error")
        .and_then(|v| v.as_str())
        .unwrap_or("invalid");
    let status = if matches!(err, "locked" | "too_soon") {
        StatusCode::TOO_MANY_REQUESTS
    } else {
        StatusCode::BAD_REQUEST
    };
    (status, Json(serde_json::json!({"error": err}))).into_response()
}
// src/main.rs
use axum::{routing::post, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::trace::TraceLayer;

mod config;
mod crypto;
mod handlers;
mod rovela;
mod service;

use config::Config;
use handlers::{send_otp, verify_otp, AppState};
use rovela::MailClient;
use service::OtpService;

#[tokio::main]
async fn main() {
    let cfg = Config::from_env();
    let mail = MailClient::new(cfg.api_key.clone(), cfg.from.clone());
    let state = Arc::new(AppState {
        otp: OtpService::new(cfg, mail),
    });

    let app = Router::new()
        .route("/auth/otp/send", post(send_otp))
        .route("/auth/otp/verify", post(verify_otp))
        // Add Tower rate-limit layers here (e.g. tower_governor) or terminate TLS at a proxy.
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
    axum::serve(listener, app).await.expect("serve");
}

Tower middleware notes: prefer a dedicated per-IP limiter (tower_governor, nginx, Cloudflare) in front of /auth/otp/*. Keep the per-email cooldown inside OtpService—IP limits and email cooldowns solve different abuse paths. Do not put ROVELA_API_KEY into request-scoped extensions that handlers might echo. Hardening checklist: Email OTP security plus the verification-code docs.

Axum vs Actix OTP (same contract, different extractors)

Axum (this post)Actix Web (sibling)
RoutingRouter + route(..., post(...))App + web::route(...).to(...)
Shared stateState<Arc<AppState>> / .with_stateweb::Data<T>
JSON bodyJson<T> extractorweb::Json<T>
MiddlewareTower layers on the routerActix middleware / services
HTTP clientreqwestreqwest
CSPRNGrand::rngs::OsRngrand::rngs::OsRng
In-proc storeDashMapDashMap
RuntimeTokio (axum::serve)Actix runtime on Tokio

Both call the same POST /emails with Idempotency-Key. Neither asks Rovela to mint or verify codes. Pick Axum when you already standardize on Tower extractors and hyper; pick Actix when your stack is already Actix services—OTP ownership stays identical.

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-axum-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."
  }'

Why not lettre / SMTP dialers for OTP in Axum

reqwest → POST /emails (this post)lettre SMTP
TransportHTTPS 443465 / 587 (25 often blocked)
IdempotencyIdempotency-Key headerApp must dedupe
FeedbackMessage id + webhooksBounce DSNs later
Containers / Cloud RunPort 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 Rust transport patterns (including lettre and webhook verification) live in Sending email in Rust.

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 Axum

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
Axum 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. Never ship sending_access into a browser or WASM client either way.

Checklist before you ship

  1. OsRng / getrandom + HMAC pepper; never store raw codes; never use a weak PRNG for OTP.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP Tower / proxy rate limit.
  3. Upsert / overwrite invalidates the previous code; verify deletes on success.
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server—never in frontend / WASM.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Prefer HTTP API over SMTP for OTP; verified from-domain; spam checklist if inbox placement slips.

Bottom line: put signup/login OTP in Axum handlers that mint OsRng codes, HMAC them at rest (DashMap, Redis, or SQL), rate-limit with Tower, and call one reqwest POST to Rovela with an Idempotency-Key—prefer that over lettre SMTP dialers, contrast routing/extractors with the Actix OTP sibling, and never pretend the email API is your auth server.