Send OTP Emails from Flutter (2026) via a Backend HTTP API
Build Flutter signup/login OTP with a Dart shelf or cloud-function BFF: Random.secure + HMAC, Idempotency-Key POST /emails—never put the sending_access key in the mobile client; Rovela delivers only.
Why Flutter needs a backend for email OTP
Flutter is excellent for signup and login UI—text fields, focus nodes, and a six-digit input that feels native on iOS and Android. It is a poor place to hold a Rovela sending_access key. Anything you ship in the APK/IPA (or in a Dart isolate that talks to the internet from the device) can be extracted. Putting ROVELA_API_KEY in --dart-define, a .env asset, or Firebase Remote Config still leaves a secret on every phone.
What you need is the recipe in Email verification codes: a server (Dart shelf, Cloud Functions, Cloud Run, or any BFF) mints a six-digit code with a CSPRNG, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POSTs https://api.rovela.dev/emails with both html and text and a stable Idempotency-Key. The Flutter app only calls your /auth/otp/send and /auth/otp/verify over HTTPS. 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 backend owns cryptography, the store entry, resend throttling, redeem logic, and the session / JWT. Rovela only delivers the mail.
This article is Flutter + Dart focused: a shelf (or Cloud Function) OTP API that talks to Rovela, plus a minimal Flutter client that never sees the sending key. It is not Sending email with Dart (generic Dart/http send, no OTP store), and not the web/Node OTP siblings (Express, Hono, Go). Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.
What you own vs what Rovela sends
Your server owns:
- CSPRNG → six digits, zero-padded (
Random.secure()in Dart—neverRandom()) - HMAC-SHA256 of
email + ":" + codewith a server pepper (CODE_PEPPER) viapackage:crypto - 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 (memory map, Redis, or SQL)
- Constant-time compare on redeem; delete / invalidate on success
- Rate limits on
/auth/otp/sendand/auth/otp/verify - Issuing the session cookie / JWT after a successful verify
Your Flutter app owns:
- Collecting email + code, calling your BFF, storing the session securely (
flutter_secure_storageor platform keychain) - UX: resend cooldown timer, error copy, offline handling
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (server env only—never in the Flutter binary, never indart-definefor release builds that ship to stores)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Architecture: phone → your API → Rovela
Flutter (UI)
│ HTTPS JSON (no Rovela key)
▼
Dart shelf / Cloud Function / Cloud Run (ROVELA_API_KEY + CODE_PEPPER)
│ POST /emails + Idempotency-Key
▼
api.rovela.dev → mailbox
Prefer a thin Dart BFF if the rest of your stack is already Dart, so OTP crypto and the mobile API stay in one language. Node, Go, or Python BFFs work the same—the Flutter side does not change. Do not call Rovela from package:http inside the Flutter project for production OTP.
Env on the server (never in the app)
# server only — Cloud Run / Function / VM secrets
export ROVELA_API_KEY="re_..." # sending_access
export CODE_PEPPER="long-random-pepper"
export OTP_FROM="Acme <security@mail.acme.com>"
export OTP_TTL_MINUTES=10
export OTP_RESEND_COOLDOWN_SEC=60
export OTP_MAX_ATTEMPTS=5
Wire keys like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders.
Dart server: mint, HMAC, POST /emails (shelf sketch)
// pubspec.yaml (server): shelf, shelf_router, http, crypto, uuid
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';
final _pepper = utf8.encode(Platform.environment['CODE_PEPPER']!);
final _apiKey = Platform.environment['ROVELA_API_KEY']!;
final _from = Platform.environment['OTP_FROM'] ?? 'Acme <security@mail.acme.com>';
final _ttl = Duration(minutes: int.parse(Platform.environment['OTP_TTL_MINUTES'] ?? '10'));
final _cooldown = Duration(seconds: int.parse(Platform.environment['OTP_RESEND_COOLDOWN_SEC'] ?? '60'));
const _maxAttempts = 5;
String normalizeEmail(String raw) => raw.trim().toLowerCase();
String mintCode() {
final n = Random.secure().nextInt(1000000); // CSPRNG — not Random()
return n.toString().padLeft(6, '0');
}
String hashCode(String email, String code) {
final mac = Hmac(sha256, _pepper);
return mac.convert(utf8.encode('$email:$code')).toString(); // hex
}
bool safeEqualHex(String a, String b) {
final aa = utf8.encode(a);
final bb = utf8.encode(b);
if (aa.length != bb.length) return false;
var diff = 0;
for (var i = 0; i < aa.length; i++) {
diff |= aa[i] ^ bb[i];
}
return diff == 0;
}
class Record {
Record({required this.codeHash, required this.expiresAt, required this.sentAt, this.attempts = 0});
String codeHash;
int attempts;
DateTime expiresAt;
DateTime sentAt;
}
final store = <String, Record>{}; // demo only — use Redis/SQL for multi-instance
Future<Map<String, dynamic>> sendOtpEmail({
required String to,
required String code,
required String idempotencyKey,
required int ttlMinutes,
}) async {
final body = {
'from': _from,
'to': to,
'subject': 'Your Acme code: $code',
'html':
'<p>Your verification code is</p>'
'<p style="font-size:28px;font-weight:600;letter-spacing:4px">$code</p>'
'<p>It expires in $ttlMinutes minutes. If you did not ask for it, ignore this email.</p>',
'text':
'Your verification code is $code. It expires in $ttlMinutes minutes. '
'If you did not ask for it, ignore this email.',
};
final res = await http.post(
Uri.parse('https://api.rovela.dev/emails'),
headers: {
'Authorization': 'Bearer $_apiKey',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
'Accept': 'application/json',
},
body: jsonEncode(body),
).timeout(const Duration(seconds: 15));
if (res.statusCode >= 300) {
throw HttpException('rovela send failed: ${res.statusCode} ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<Response> handleSend(Request req) async {
final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
final email = normalizeEmail('${payload['email'] ?? ''}');
if (email.isEmpty || !email.contains('@')) {
return Response(400, body: jsonEncode({'error': 'invalid_email'}));
}
final now = DateTime.now().toUtc();
final existing = store[email];
if (existing != null && now.difference(existing.sentAt) < _cooldown) {
return Response(429, body: jsonEncode({'error': 'too_soon'}));
}
final code = mintCode();
final codeHash = hashCode(email, code);
store[email] = Record(
codeHash: codeHash,
expiresAt: now.add(_ttl),
sentAt: now,
);
// Stable per logical send: hash prefix. New code ⇒ new key.
final idem = 'otp-${codeHash.substring(0, 32)}';
await sendOtpEmail(
to: email,
code: code,
idempotencyKey: idem,
ttlMinutes: _ttl.inMinutes,
);
// Uniform copy — product choice whether to reveal account existence
return Response.ok(jsonEncode({
'message': 'If that address can receive mail, a code is on its way.',
}));
}
Future<Response> handleVerify(Request req) async {
final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
final email = normalizeEmail('${payload['email'] ?? ''}');
final code = '${payload['code'] ?? ''}'.trim();
if (code.length != 6 || int.tryParse(code) == null) {
return Response(400, body: jsonEncode({'error': 'invalid'}));
}
final rec = store[email];
final now = DateTime.now().toUtc();
if (rec == null) return Response(400, body: jsonEncode({'error': 'invalid'}));
if (now.isAfter(rec.expiresAt)) {
store.remove(email);
return Response(400, body: jsonEncode({'error': 'expired'}));
}
if (rec.attempts >= _maxAttempts) {
return Response(429, body: jsonEncode({'error': 'locked'}));
}
if (!safeEqualHex(hashCode(email, code), rec.codeHash)) {
rec.attempts++;
return Response(400, body: jsonEncode({'error': 'invalid'}));
}
store.remove(email);
// issue session / JWT here
return Response.ok(jsonEncode({'ok': true, 'token': 'replace-with-jwt'}));
}
void main() async {
final router = Router()
..post('/auth/otp/send', handleSend)
..post('/auth/otp/verify', handleVerify);
final handler = const Pipeline()
.addMiddleware(logRequests())
// add IP rate-limit middleware in production
.addHandler(router.call);
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
print('OTP BFF on :${server.port}');
}
Suggested SQL 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()
);
Cloud Function note: the same sendOtpEmail / HMAC helpers work in a Dart or Node function triggered by HTTPS. Keep secrets in the function’s secret manager—not in Flutter.
Putting the code in the subject helps lock-screen previews—fine for many products; skip it on highly shared screens. Always send both html and text with the digits in the text part (POST /emails).
Flutter client: call your BFF only
// lib/auth/otp_api.dart — NO Rovela key here
import 'dart:convert';
import 'package:http/http.dart' as http;
class OtpApi {
OtpApi(this.baseUrl); // e.g. https://api.yourapp.com
final String baseUrl;
Future<void> sendCode(String email) async {
final res = await http.post(
Uri.parse('$baseUrl/auth/otp/send'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email.trim()}),
);
if (res.statusCode == 429) {
throw StateError('too_soon');
}
if (res.statusCode >= 300) {
throw StateError('send_failed');
}
}
Future<String> verifyCode(String email, String code) async {
final res = await http.post(
Uri.parse('$baseUrl/auth/otp/verify'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email.trim(), 'code': code.trim()}),
);
if (res.statusCode >= 300) {
throw StateError('invalid_or_locked');
}
final map = jsonDecode(res.body) as Map<String, dynamic>;
return map['token'] as String; // store securely
}
}
// lib/screens/otp_login.dart (sketch)
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class OtpLoginScreen extends StatefulWidget {
const OtpLoginScreen({super.key, required this.api});
final OtpApi api;
@override
State<OtpLoginScreen> createState() => _OtpLoginScreenState();
}
class _OtpLoginScreenState extends State<OtpLoginScreen> {
final emailCtrl = TextEditingController();
final codeCtrl = TextEditingController();
var step = 0; // 0 = email, 1 = code
var busy = false;
String? error;
Future<void> _send() async {
setState(() { busy = true; error = null; });
try {
await widget.api.sendCode(emailCtrl.text);
setState(() => step = 1);
} catch (e) {
setState(() => error = e.toString());
} finally {
setState(() => busy = false);
}
}
Future<void> _verify() async {
setState(() { busy = true; error = null; });
try {
final token = await widget.api.verifyCode(emailCtrl.text, codeCtrl.text);
// persist token with flutter_secure_storage; navigate home
debugPrint('session ready (${token.length} chars)');
} catch (e) {
setState(() => error = e.toString());
} finally {
setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (step == 0) ...[
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: busy ? null : _send,
child: Text(busy ? 'Sending…' : 'Send code'),
),
] else ...[
TextField(
controller: codeCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 6,
autofillHints: const [AutofillHints.oneTimeCode],
decoration: const InputDecoration(labelText: '6-digit code'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: busy ? null : _verify,
child: Text(busy ? 'Checking…' : 'Verify'),
),
TextButton(
onPressed: busy ? null : _send,
child: const Text('Resend code'),
),
],
if (error != null) Text(error!, style: const TextStyle(color: Colors.red)),
],
),
),
);
}
}
Use AutofillHints.oneTimeCode so iOS/Android can suggest codes from the notification. Still treat SMS Autofill libraries as optional—email OTP arrives in the Mail app, not as an SMS Retriever payload.
Idempotency-Key: retries without double codes
Workers, Cloud Functions retries, and flaky mobile networks all 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.
- The Flutter client does not set this header on Rovela; your BFF does when it calls
POST /emails.
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-flutter-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 call Rovela from the Flutter app
BFF → POST /emails (this post) | Flutter → Rovela directly | |
|---|---|---|
sending_access key | Server secret manager | Extractable from the binary |
| OTP mint + HMAC store | Server | Hard to share across devices safely |
| Rate limits | Per IP + per email on your edge | Attacker uses your key as a free relay |
| Idempotency | Stable key per mint on server | Easy to get wrong on flaky mobile retries |
| Session issue | After verify on server | Still need a backend for a real session |
Generic Dart/http “send a welcome mail from a CLI or server” patterns live in Sending email with Dart. That post is transport-focused; this post is signup/login OTP with a mobile client boundary.
Deliverability notes for auth digits
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 for Flutter
| Six-digit OTP (this post) | Magic link | |
|---|---|---|
| User action | Type digits (good on mobile keyboards) | Open URL (deep link / App Link) |
| Secret entropy | Low → HMAC + pepper required | High URL token → SHA-256 of token is usually enough |
| Docs shape | Verification codes | Password reset / link pattern |
| Flutter surface | Email + PIN fields → your BFF | Deep link handler → your BFF |
| Click tracking | Optional concern | Prefer off on the security sending domain |
Both call the same POST /emails from the server. Keep separate stores—do not reuse an OTP record as a magic-link token.
Checklist before you ship
Random.secure()(or OS CSPRNG) + HMAC pepper on the server; never store raw codes; never use non-secureRandom()for OTP.- ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit on the BFF.
- Upsert / overwrite invalidates the previous code; verify deletes on success.
htmlandtext; optional subject code for previews.ROVELA_API_KEY(sending_access) only on the server—not in Flutter, not in--dart-definefor store builds.Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- Flutter talks only to your API; use
AutofillHints.oneTimeCode; store session tokens in secure storage. - Verified from-domain; spam checklist if inbox placement slips.
Related reading
- Docs: Email verification codes · POST /emails · Quick Start · Authentication.
- Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
- UX choice: Email verification: code vs link.
- Deliverability: Why OTP and magic-link emails go to spam.
- Framework siblings: Express OTP · Go OTP · Hono OTP · Django OTP.
- Dart transport only (not OTP): Sending email with Dart.
Bottom line: keep Flutter as the OTP UI, put mint/HMAC/verify and the Rovela sending_access key on a Dart shelf (or any) BFF, call POST /emails with an Idempotency-Key, and never ship the sending key inside the mobile client—Rovela delivers the mail; your app still owns auth.