New Livestream: How Banks Protect Their Apps with FAPI 2.0.

Register Now!

WhatsApp One-Time Password (OTP) Login with Duende IdentityServer and User Management

Two blue circles

One-Time Passwords (OTP) over SMS are often the default for phone-based login: every phone can receive a text, users know the drill, and it works most of the time. However, delivery rates vary wildly by country, carrier filtering silently drops codes, and every message costs money. On top of that, phone numbers can be hijacked. If your users already spend their day in WhatsApp (roughly 2 billion people do), delivering the code there means better deliverability and often a lower price per message.

Duende User Management comes with numerous extension points, and lets you add WhatsApp-based OTP through one interface, IOtpDispatcher, and point it at Meta's WhatsApp Business Cloud API. The WhatsApp OTP dispatcher implementation is on GitHub; we'll walk through the code below.

What Is Duende User Management?

Duende User Management is an (optional) core part of Duende IdentityServer available to everyone, and handles the user-facing side of identity: registration, login, account management, and passwordless flows including OTP. It ships with an OTP pipeline out of the box. You wire up a store, and the framework handles generating codes, rate-limiting sends, and verifying submissions. You don't need to build any of that yourself.

The OTP pipeline is built around two interfaces: IOtpSender (generates and dispatches codes) and IOtpDispatcher (delivers the code via a specific channel). IOtpSender is provided by the framework and injected; IOtpDispatcher is what you implement when you want a different delivery channel. User Management ships with an email dispatcher, and it is designed so you can swap or extend it.

The sample targets .NET 10 and uses three NuGet packages: Duende.IdentityServer, Duende.UserManagement.IdentityServer8 (the suffix tracks the IdentityServer major version, so don't go looking for a package named just Duende.UserManagement), and Duende.Storage.Sqlite for the store.

The IOtpDispatcher Extension Point

User Management generates, stores, and verifies the OTP code, then hands it off to a dispatcher. IOtpDispatcher's job is then to get it to the user over some channel. The interface has two members:

Csharp

public bool CanDispatch(OtpAddress address);

public Task DispatchAsync(
    OtpAddress address,
    PlainTextOtp otp,
    TimeSpan expiresAfter,
    CancellationToken ct);

CanDispatch lets the dispatcher declare what it handles. If you return false, User Management moves on to the next registered dispatcher. DispatchAsync does the actual delivery. Multiple dispatchers can coexist: you could keep the default email dispatcher registered and add a WhatsApp one on top, routing by channel automatically.

A note on channels: OtpChannel.Sms is the built-in channel for phone-number addresses, and there is no separate WhatsApp channel (nor do you need one). By claiming Sms-channel addresses, our dispatcher intercepts every phone-number OTP request, regardless of how the code ultimately travels.

Setting Up the WhatsApp Business Cloud API

To send messages, you need a Meta developer account, a verified Meta Business, and a WhatsApp Business Account (WABA) with a registered sender phone number. The Meta WhatsApp Cloud API docs cover all of this. The sample README has a step-by-step walkthrough.

The short version: create a Meta app with WhatsApp enabled, note your Phone Number ID (a numeric ID, not the phone number itself) and WABA ID, create a permanent System User token with whatsapp_business_messaging permissions, and set up an authentication category message template named something like otp_code. You don't have to build the template from scratch: WhatsApp Manager has a template library with a ready-made one-time passcode template you can pick and submit as-is. Meta usually pre-approves authentication templates within minutes.

Templates exist per language. The same template name can have an en_US variant, an nl variant, and so on, and the send request picks one by name plus language code. That combination is exactly what TemplateName and TemplateLanguage in the sample's configuration map to, so whatever you approve here has to match your config, character for character.

Create template in WhatsApp portal

Why a template at all? WhatsApp does not let you send free-form text for OTP; authentication messages must use an approved template. The template has a body parameter (where the code goes) and a "Copy code" button (which also gets the code). This is Meta's format for authentication templates, and you can't deviate from it. You can also add a security recommendation and validity period.

Edit OTP template in WhatsApp portal

Implementing WhatsAppOtpDispatcher

WhatsAppOtpDispatcher implements IOtpDispatcher. It accepts OtpChannel.Sms addresses so it intercepts phone-number-based OTP requests, normalizes the number to digits only, builds a template message payload, and posts it to Meta's Graph API.

Here is CanDispatch:

Csharp

public bool CanDispatch(OtpAddress address) => address.Channel == OtpChannel.Sms;

This returns true only for SMS-channel addresses. Email addresses fall through to whatever other dispatcher you have registered, i.e. the default email one, if you keep it.

DispatchAsync does the work:

Csharp

public async Task DispatchAsync(
    OtpAddress address,
    PlainTextOtp otp,
    TimeSpan expiresAfter,
    CancellationToken ct)
{
    var to = NormalizePhoneNumber(address.SubjectId.ToString());
    var code = otp.Text;

    var payload = BuildTemplatePayload(to, code);

    var json = JsonSerializer.Serialize(payload, JsonOptions);
    using var content = new StringContent(json, Encoding.UTF8, "application/json");

    var requestUri = $"{_options.PhoneNumberId}/messages";

    using var response = await httpClient.PostAsync(requestUri, content, ct);

    if (!response.IsSuccessStatusCode)
    {
        var body = await response.Content.ReadAsStringAsync(ct);
        logger.LogError(
            "WhatsApp OTP delivery to {PhoneNumberId} failed with status {StatusCode}: {Body}",
            _options.PhoneNumberId, (int)response.StatusCode, body);

        throw new InvalidOperationException(
            $"WhatsApp OTP delivery failed with status {(int)response.StatusCode}.");
    }
}

Phone number normalization strips everything that is not a digit:

Csharp

private static string NormalizePhoneNumber(string phoneNumber)
    => NonDigits().Replace(phoneNumber, string.Empty);

[GeneratedRegex(@"\D")]
private static partial Regex NonDigits();

The WhatsApp Cloud API expects the number in E.164 format using only digits, no +, no spaces. +15551234567 becomes 15551234567.

The template payload is what Meta requires for authentication templates. The OTP code appears twice, once in the body and once in the button, because that is the format:

Csharp

private object BuildTemplatePayload(string to, string code) => new
{
    messaging_product = "whatsapp",
    recipient_type = "individual",
    to,
    type = "template",
    template = new
    {
        name = _options.TemplateName,
        language = new { code = _options.TemplateLanguage },
        components = new object[]
        {
            new
            {
                type = "body",
                parameters = new[] { new { type = "text", text = code } },
            },
            new
            {
                type = "button",
                sub_type = _options.ButtonSubType,
                index = "0",
                parameters = new[] { new { type = "text", text = code } },
            },
        },
    },
};

ButtonSubType is "url" in the configuration. That might look odd for a Copy-code button, but it is what Meta requires on the send request. The template creation uses otp_type: COPY_CODE, but Meta stores it internally as a URL button. Sending copy_code on the API call fails with (#132018) Button at index 0 must be of type Url. More on this in the gotchas section.

Configuring the Dispatcher

The sample keeps configuration in appsettings.json under a WhatsApp section. In your own app, it can come from wherever you store configuration (environment variables, a configuration service, ...), since it binds through the standard options pattern:

Json

{
  "WhatsApp": {
    "GraphApiBaseUrl": "https://graph.facebook.com/v21.0/",
    "PhoneNumberId": "",
    "AccessToken": "",
    "TemplateName": "otp_code",
    "TemplateLanguage": "en_US",
    "ButtonSubType": "url"
  }
}

Do not put the access token in source control. Use .NET user secrets locally:

Sh

dotnet user-secrets set "WhatsApp:PhoneNumberId" "<YOUR_PHONE_NUMBER_ID>"
dotnet user-secrets set "WhatsApp:AccessToken"   "<YOUR_ACCESS_TOKEN>"

The WhatsAppOptions class maps these to a strongly typed object:

Csharp

public class WhatsAppOptions
{
    public string GraphApiBaseUrl { get; set; } = "https://graph.facebook.com/v21.0/";
    public string PhoneNumberId { get; set; } = string.Empty;
    public string AccessToken { get; set; } = string.Empty;
    public string TemplateName { get; set; } = "otp_code";
    public string TemplateLanguage { get; set; } = "en_US";
    public string ButtonSubType { get; set; } = "url";
}

Registering the WhatsApp Dispatcher

WhatsAppServiceCollectionExtensions wires up a typed HttpClient (pre-configured with the Graph API base URL and bearer token) and registers the dispatcher against IOtpDispatcher:

Csharp

public static IServiceCollection AddWhatsAppOtpDispatcher(
    this IServiceCollection services,
    IConfiguration configuration)
{
    services.AddOptions<WhatsAppOptions>()
        .Bind(configuration.GetSection("WhatsApp"));

    services.AddHttpClient<WhatsAppOtpDispatcher>((sp, client) =>
    {
        var options = sp.GetRequiredService<
            IOptions<WhatsAppOptions>>().Value;

        client.BaseAddress = new Uri(options.GraphApiBaseUrl);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", options.AccessToken);
    });

    services.AddTransient<IOtpDispatcher>(sp =>
        sp.GetRequiredService<WhatsAppOtpDispatcher>());

    return services;
}

In Program.cs, the entire setup is a single line added to the existing User Management registration:

Csharp

builder.Services
    .AddIdentityServer(...)
    .AddUserManagement(options =>
    {
        options.AddSqliteStore(o =>
        {
            o.ConnectionString = "Data Source=usermanagement.db";
        });
    });

builder.Services.AddWhatsAppOtpDispatcher(builder.Configuration);

That is the extension point in practice. The rest of IdentityServer and User Management stay unchanged.

How the Login Flow Works

The Login page asks for a phone number:

Duende IdentityServer One-Time Password (OTP) login

On submit, it calls IOtpSender.TrySendOtpAsync, passing an OtpAddress with OtpChannel.Sms:

Csharp

result = await otpSender.TrySendOtpAsync(
    new OtpAddress(OtpChannel.Sms, phoneNumber),
    HttpContext.RequestAborted);

User Management generates the code, stores a hashed version, and calls your dispatcher. If delivery succeeds, TrySendOtpAsync returns SendOtpResult.Sent with a token the page stores in TempData:

Csharp

if (result is SendOtpResult.Sent sentResult)
{
    TempData["OtpToken"] = sentResult.Token.Value.ToString();
    TempData["ReturnUrl"] = returnUrl;
    return RedirectToPage("/Account/EnterOtp");
}
WhatsApp showing OTP code

If the phone has been blocked due to too many attempts, you get SendOtpResult.Blocked with a backoff timestamp. Dispatch failures (misconfigured token, network error) bubble as exceptions, which the page catches and turns into a friendly model error instead of a 500.

Duende IdentityServer Enter One-Time Password

On EnterOtp, the user types the code. IOtpAuthenticator.TryAuthenticateAsync validates the code against the stored hash, handles expiry, and returns OtpAuthenticationResult.Success on a match. A wrong or expired code returns a non-success result; the sample adds a model error ("Invalid or expired code") and keeps the user on the page to retry until the token expires. On success, the page signs the user in using IdentityServer's cookie scheme and redirects to the return URL:

Csharp

var authResult = await otpAuthenticator.TryAuthenticateAsync(
    otp, token, HttpContext.RequestAborted);

if (authResult is OtpAuthenticationResult.Success otpSuccess)
{
    var claims = new List<Claim>
    {
        new("sub", otpSuccess.UserSubjectId.ToString()!),
        new(ClaimTypes.Name, otpSuccess.Address.SubjectId.ToString()!),
    };

    await HttpContext.SignInAsync(
        IdentityServerConstants.DefaultCookieAuthenticationScheme,
        new ClaimsPrincipal(new ClaimsIdentity(claims, "otp")),
        new AuthenticationProperties());

    returnUrl = interaction.IsValidReturnUrl(returnUrl) ? returnUrl : "~/";
    return LocalRedirect(returnUrl!);
}

Note the IsValidReturnUrl check before redirecting. IdentityServer's IIdentityServerInteractionService verifies the return URL belongs to a valid authorization request, so an attacker can't abuse the login page as an open redirect. Keep that line when you adapt this code.

First-time login with a phone number auto-registers the user. No separate registration step needed. The user records land in the User Management store, and you don't have to create the schema by hand either: the sample calls IDatabaseSchema.MigrateAsync at startup (see Program.cs), which creates and migrates the tables for whichever store provider you registered.

Gotchas You Will Likely Hit

A few things that will waste your afternoon if you do not know them ahead of time:

Template name and language must match exactly. TemplateName and TemplateLanguage must match exactly what you created in the WhatsApp Manager, including the case and locale code. A mismatch returns (#132001) Template name does not exist in <language>. The language is the full locale code (en_US, not en), unless you created it with just en.

ButtonSubType must be "url". As mentioned above, the send API requires url even for Copy-code buttons. Setting it to copy_code returns (#132018) Button at index 0 must be of type Url.

The System User token must have the WABA assigned as an asset. Even with the right permissions, a token that is not explicitly assigned to the WhatsApp Business Account and phone number returns (#100) ... does not exist, cannot be loaded due to missing permissions with subcode 33. In Business Settings, go to Users, System Users, and under the system user's assets, add both your app and the specific WABA with Full control. The sample README has the exact steps.

Before You Go to Production

The sample uses SQLite and an in-memory configuration, which is fine for local development. For production, swap a few things.

Use a proper database. Duende provides Duende.Storage.Postgresql and Duende.Storage.Mssql as drop-in replacements for the SQLite store. Change the AddSqliteStore call to AddPostgresqlStore (or equivalent) and update the connection string.

Persist Data Protection keys to a shared durable location. The default in-memory or file-based key ring will not survive restarts or multi-instance deployments.

Mind WhatsApp's per-number messaging tier limits. New phone numbers start at a low message-per-day limit. The limit increases automatically with usage and verification. For high-volume OTP scenarios, review Meta's business verification and messaging tier documentation before launch.

When to Use This (and When Not To)

This approach is a good fit when your users are in regions with strong WhatsApp adoption (much of Europe, Latin America, South Asia, and Africa), when SMS delivery is unreliable or expensive in your target market, or when you want a passwordless flow that does not require users to remember anything.

It is probably not the right fit if your users are primarily in the US or Canada, where iMessage is more dominant, and WhatsApp adoption is lower. It also requires a Meta developer account and a verified business, which adds setup overhead compared to a simple SMS provider. If you are already set up with a reliable SMS gateway, the friction may not be worth it for a small user base.

WhatsApp OTP also works well as a stepping stone. You can start users on OTP (no password to create, nothing to install) and progressively move them to passkeys once they have signed in a few times, keeping WhatsApp as the recovery channel when someone loses their device. Both flows run on the same User Management pipeline, so offering them side by side is configuration, not a rebuild.

For more examples of extending User Management, the HIBP breached password check integration and restricting passkey login to hardware keys show the same pattern applied to different extension points. You control your identity infrastructure.

Give it a spin. The full sample is on GitHub. Let us know how it goes, and we're curious to hear (and see) what other OTP dispatchers you build!

Related Articles