← Back to blog

AWS Cognito Custom Email with Rovela (2026)

Cognito Custom Email Sender Lambda delivers verification, forgot-password, and invite mail via Rovela POST /emails with Idempotency-Key—Cognito owns auth; Rovela delivers. Not a Cognito/SES replacement; distinct from Firebase, Clerk, Supabase.

Updated November 4, 2026
AWS Cognito Custom Email with Rovela (2026)

Cognito owns auth — Rovela delivers Cognito-triggered mail

Amazon Cognito user pools own sign-up, sign-in, forgot-password, MFA/email OTP, admin invites, and attribute verification. By default Cognito delivers those messages through its built-in email path (often Amazon SES when you configure it). Many teams keep Cognito for identity but want branded HTML, a dedicated mail. subdomain reputation, bilingual copy, or a flat HTTP send API they already use elsewhere.

This guide wires Cognito’s Custom email sender Lambda (and notes when Custom message is enough) so verification codes, password-reset codes, resend codes, and admin invite / temporary-password mail leave through Rovela’s POST https://api.rovela.dev/emails with an Idempotency-Key. Cognito still creates users, validates codes, and runs the auth lifecycle; your Lambda only decrypts the Cognito-provided secret and delivers the message.

Rovela does not replace Cognito. It does not create user pools, issue tokens, confirm sign-up, or redeem forgot-password codes. It also does not replace Amazon SES for every AWS workload—see Amazon SES alternative: simple HTTP for the general SES comparison. Here Rovela is only the custom transactional path for Cognito-triggered emails.

This is not Firebase Auth custom email, not Clerk, and not Supabase. The integration surface is Cognito user-pool Lambda triggers + AWS KMS decryption + a trusted sender calling Rovela.

Custom message vs Custom email sender

TriggerWhat Cognito doesWhen to use
Custom messageLets you customize subject/body placeholders; Cognito still sends the email itselfLight branding while keeping Cognito’s default delivery
Custom email senderCognito invokes your Lambda instead of sending; you must deliver every emailYou want Rovela (or any third-party HTTP API) as the transport

For Rovela, use Custom email sender. Custom message alone will not call POST /emails.

Typical triggerSource values on the custom email sender (see AWS docs for the full table):

  • CustomEmailSender_SignUp — confirmation / welcome after sign-up
  • CustomEmailSender_ResendCode — replacement confirmation code
  • CustomEmailSender_ForgotPassword — password-reset code
  • CustomEmailSender_VerifyUserAttribute / CustomEmailSender_UpdateUserAttribute — attribute verification
  • CustomEmailSender_Authentication — email OTP / MFA-style email code
  • CustomEmailSender_AdminCreateUser — invite / temporary password
  • CustomEmailSender_AccountTakeOverNotification — account-takeover notice

What you own vs what Rovela sends

Your AWS account (user pool + Lambda) owns:

  • Cognito user pools, clients, and auth flows
  • Assigning the Custom email sender Lambda and a customer managed KMS key (Cognito encrypts codes / temporary passwords for the trigger)
  • Decrypting event.request.code with the AWS Encryption SDK + kms:Decrypt
  • HTML/text templates, locale, and rate / abuse policy around Cognito APIs
  • A stable Idempotency-Key per logical Cognito event (Lambda retries are common)
  • Secrets: Rovela API key and from in Lambda env / Secrets Manager—never in a browser or mobile app

Rovela owns:

  • POST /emails with Authorization: Bearer + a sending_access key (Authentication)
  • Queueing, DKIM signing, retries, bounce/complaint handling

A 200 means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer mail.example.com for auth mail (Why OTP and magic-link emails go to spam).
  2. An API key that looks like re_…. Prefer sending_access for production senders.
  3. A Cognito user pool and a Lambda in the same region you configure for custom senders.
  4. A KMS customer managed key Cognito can use to encrypt trigger secrets; your Lambda role needs decrypt permissions (follow AWS custom sender docs).
  5. Never put the Rovela key in Amplify client config, a React Native / Flutter bundle, or a public repo.

Configure the Custom email sender (high level)

Follow AWS’s custom email sender and custom sender triggers guides for the exact UpdateUserPool / LambdaConfig fields (CustomEmailSender, KMSKeyID). Configuration is API/CLI/CDK oriented; do not invent console toggles that may not exist.

Sketch (CDK-ish intent—adapt to your IaC):

// Illustrative: wire CustomEmailSender + KMSKeyID on the user pool LambdaConfig.
// Use the current AWS Cognito API shapes from the docs—not a drop-in snippet.
userPool.addTrigger(
  /* CustomEmailSender */ "CustomEmailSender",
  customEmailSenderFn,
);
// Also grant Cognito encrypt and Lambda decrypt on your CMK per AWS docs.

Store secrets on the function:

# Example names — use Secrets Manager or SSM Parameter Store in real deploys
aws lambda update-function-configuration \
  --function-name cognito-custom-email-sender \
  --environment "Variables={ROVELA_API_KEY=re_your_sending_access_key,ROVELA_FROM=Acme <security@mail.example.com>}"

Prefer Secrets Manager over plain env for the Rovela key. Rotate by creating a new Rovela key, updating the secret, then revoking the old key in the dashboard (Authentication).

Lambda: decrypt Cognito’s code, then POST Rovela

Cognito does not hand your function a plaintext code in the clear. The custom sender event carries an encrypted secret; your code decrypts it with the AWS Encryption SDK and the configured KMS key, then builds the email. The sketch below keeps decryption behind a helper—implement that helper exactly as AWS documents for your runtime (Node, Python, etc.).

// cognito-custom-email-sender.ts — illustrative Node 20+ handler
import type { CustomEmailSenderTriggerHandler } from "aws-lambda";

const ROVELA_URL = "https://api.rovela.dev/emails";

// Implement with AWS Encryption SDK + KMS per Cognito custom sender docs.
declare function decryptCognitoCode(
  encryptedCode: string,
  kmsKeyArn: string,
): Promise<string>;

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function subjectFor(source: string, code: string): string {
  switch (source) {
    case "CustomEmailSender_ForgotPassword":
      return `Your Acme reset code: ${code}`;
    case "CustomEmailSender_AdminCreateUser":
      return "Your Acme temporary password";
    case "CustomEmailSender_Authentication":
      return `Your Acme sign-in code: ${code}`;
    default:
      return `Your Acme verification code: ${code}`;
  }
}

export const handler: CustomEmailSenderTriggerHandler = async (event) => {
  const email = event.request.userAttributes?.email;
  if (!email) {
    // Cognito invoked you because an email was required—fail loud so the
    // invocation is retried or alerted rather than silently dropping.
    throw new Error("missing_email_attribute");
  }

  const encrypted = event.request.code;
  if (!encrypted) {
    throw new Error("missing_encrypted_code");
  }

  const code = await decryptCognitoCode(
    encrypted,
    event.request.kmsKeyId /* field name per current AWS event shape */,
  );

  const apiKey = process.env.ROVELA_API_KEY!;
  const from = process.env.ROVELA_FROM!;
  const source = event.triggerSource;
  const sub = event.userName || email;
  // Stable across Lambda retries of the same Cognito event; new Cognito
  // resend APIs produce a new code → use a new key (include a request id if present).
  const idem =
    `cognito-${source}-${sub}-${code.slice(0, 8)}`.slice(0, 256);

  const safeCode = escapeHtml(code);
  const html =
    `<p>Your code is</p>` +
    `<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${safeCode}</b></p>` +
    `<p>If you did not request this, you can ignore this email.</p>`;
  const text =
    `Your code is ${code}. If you did not request this, you can ignore this email.`;

  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": idem,
    },
    body: JSON.stringify({
      from,
      to: email,
      subject: subjectFor(source, code),
      html,
      text,
    }),
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    // Throwing lets Lambda retry transient failures; keep the same Idempotency-Key.
    console.error("rovela_send_failed", res.status, data);
    throw new Error(`rovela_send_failed:${res.status}`);
  }

  // Custom email sender handlers typically return void / the event after send.
  return;
};

Plain curl shape of the same send (Send Email API):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cognito-CustomEmailSender_ForgotPassword-alice-48291301" \
  -d '{
    "from": "Acme <security@mail.example.com>",
    "to": "alice@example.com",
    "subject": "Your Acme reset code: 482913",
    "html": "<p>Your code is</p><p style=\"font-size:28px;letter-spacing:6px\"><b>482913</b></p>",
    "text": "Your code is 482913. If you did not request this, ignore this email."
  }'

Response:

{"object": "email", "id": "9f1c2e40-..."}

Branching by triggerSource

Keep one Lambda and branch copy—not five separate Cognito transports:

triggerSourceMail intentNotes
…_SignUp / …_ResendCodeConfirm accountCognito still owns code validity
…_ForgotPasswordResetSame
…_AdminCreateUserInvite / temp passwordTreat temporary passwords like secrets in logs
…_AuthenticationEmail OTP / MFA emailHigh urgency; short copy; code in subject helps mobile previews
…_VerifyUserAttribute / …_UpdateUserAttributeVerify new email

Do not log plaintext codes or temporary passwords. Prefer structured logs with triggerSource, Cognito userName, and the Rovela message id only.

Idempotency on Cognito + Lambda retries

Cognito and Lambda can retry. Without Idempotency-Key, a timeout can deliver two codes that disagree.

Per the emails API:

  • Header ≤ 256 characters
  • Remembered for 24 hours; replay returns the original response
  • Derive from Cognito event identity + a fingerprint of this code attempt
  • A deliberate Cognito resend that mints a new code must use a new key

Errors you will actually hit

Same shape everywhere (Quick Start):

  • 400 naming your domain — from not verified in this Rovela org
  • 400 recipient is suppressed: … — prior bounce/complaint
  • 401 — bad or revoked key
  • 429 — honour Retry-After; keep the same Idempotency-Key on retry

If Rovela refuses the send, throw from the Lambda so Cognito/Lambda retry policy can apply—or enqueue to SQS for controlled retries. Do not tell the end user “check your inbox” when the API never accepted the message.

Checklist

  1. Verify mail.example.com (or your domain) in Rovela; SPF/DKIM/DMARC pass
  2. Create a sending_access key; store only in Lambda secrets—never in Cognito app clients
  3. Configure Custom email sender + KMS CMK per AWS docs (not Custom message alone)
  4. Decrypt Cognito’s encrypted code with the Encryption SDK; never invent plaintext fields
  5. fetch Rovela with both html and text + Idempotency-Key
  6. Branch templates by triggerSource; avoid logging secrets
  7. Watch delivery via Rovela dashboard or webhooks—not by assuming 200 means inbox

Soft docs: Send Email · Authentication · Email verification codes · Quick Start.

  • support@rovela.dev