Start With the Verification Contract

Before choosing a WhatsApp library, define the contract between the application and the verification service. A request should have an authenticated application identity, an intended recipient, a purpose and a durable request ID. The application must bind that request ID to its own user or pending action on the server. Checking a valid code for an arbitrary client-supplied request ID is not enough to authenticate the right account.

Separate the result of sending from the result of verification. A send acknowledgement says something about transport progress; successful verification says that the submitted code matched a still-valid challenge. Neither automatically creates a secure application session. OWASP's Authentication Cheat Sheet treats authentication and session management as distinct responsibilities. Your application still owns its session issuance, permissions and account lifecycle.

Keep Owner Setup, Number Pairing and Application Access Separate

A self-hosted dashboard normally has three different trust boundaries. The installation owner registers once using a private bootstrap secret and then signs in with an administrative session. The owner links an existing sending number. Finally, each integrating application receives its own revocable server-side API key. A dashboard cookie should not silently authorize application OTP endpoints, and an application key should not be allowed to read pairing credentials or administer the installation.

The Baileys documentation describes a WebSocket-based WhatsApp Web connection and QR/pairing-code authentication. Linking a device does not create a new WhatsApp account. Check the actual linked account against the number the operator registered, store session credentials privately, and make reconnect and removal states visible. Transport selection is a separate decision; the library's relationship to WhatsApp and provider considerations should be clear in the deployment README.

Generate Random Codes and Store a Keyed Verifier

OWASP's Forgot Password Cheat Sheet recommends cryptographically generated codes, secure storage, single use and an appropriate expiry. Although that guide addresses account recovery, those properties are useful design requirements for other short-lived verification challenges too. They do not turn every OTP flow into a complete account-recovery design.

For a six-digit challenge, Node's crypto.randomInt(0, 1_000_000), padded to six characters, is preferable to Math.random. The Node documentation explicitly notes that randomInt avoids modulo bias. Keep the code as a string so leading zeroes are preserved.

A six-digit space has only one million possibilities. An ordinary unkeyed hash can therefore be tested offline cheaply after a database leak. A keyed verifier such as HMAC(secret, requestId + ':' + code) changes that threat model: a database-only attacker also needs the separately managed secret to test candidates. Protect the secret and backups; this does not protect against a whole-host compromise. Never return the code in API status responses or store it in routine request logs.

Make Single Use an Atomic State Transition

Checking a verifier and marking it used must be one atomic operation. Otherwise two concurrent correct submissions can both pass before either writes the consumed state. Use a transaction or a conditional state transition that verifies the request's application ownership, active state, expiry and attempt count before consuming it. Persist wrong attempts as well; restarting the service must not reset the guessing budget.

Choose and document expiry, attempt and resend rules. A five-minute TTL, five attempts and sixty-second cooldown are implementation defaults, not universal standards. Distinguish an expired request from a locked or superseded one. When a new code replaces an older challenge for the same application and recipient, make the old verifier unusable so delayed messages do not leave several valid codes in circulation.

Design the Timeout Path Before the Happy Path

Consider this sequence: the gateway reserves a challenge, hands the message to the transport, and loses the acknowledgement before updating its database. The recipient may already have the code. Retrying the send blindly can produce duplicate messages and conflicting challenges. A generic HTTP error cannot tell the application whether the recipient received anything.

Reserve before sending and require an idempotency key scoped to the authenticated application. A retry with the same key and request data should return the same request instead of dispatching another message; a changed recipient or sending device should conflict. Retention matters: an idempotency record retained for twenty-four hours cannot prevent duplicate logical requests retried after it has been deleted.

Model sending, accepted, delivered/read and delivery_unknown separately. Accepted can mean that the adapter returned a message ID; a matching receipt is stronger delivery evidence but can still be absent. Preserve an ambiguous send after restart and do not automatically replay it. If the recipient enters the correct code while it remains valid, verification can succeed even when the transport acknowledgement is uncertain.

Rate Limits Need More Than an API-Key Bucket

A stolen or misused key should not be able to flood one recipient or monopolize a sending number. Combine application quotas, recipient cooldowns, per-recipient limits, sending-device limits and observed-IP throttles. OWASP's recovery guidance explicitly calls for protection against excessive automated submissions, including per-account rate limiting or other anti-automation controls. Requiring consent=true records a caller assertion; it is not independent proof that the recipient asked for a code.

Persistent counters keep a restart from resetting the budget. Fixed windows can still burst near their boundaries, and a single SQLite store is not a distributed quota across installations. Reverse-proxy trust must also be explicit: accepting arbitrary forwarded client-IP headers lets callers invent new identities for an IP limiter. Keep the backend private and trust only the reviewed proxy path.

Use OpenAPI as an Executable Integration Contract

The OpenAPI 3.1 specification provides schemas, operation descriptions and security-scheme declarations. Define the request body, idempotency header, verification response and relevant errors alongside the server's route validation, rather than maintaining a separate example document that can drift. Declaring bearer authentication in a specification documents the requirement; the server still has to enforce it.

An owner-authenticated Swagger UI can make integration easier with Authorize and Try it out. Clearly distinguish those real operations from sample responses: a request button can send a WhatsApp message. Do not persist authorization keys in browser storage. Document timeout/retry behavior, Retry-After, expiry, consumed-code errors and the application's responsibility to bind request IDs to its own session.

An Inspectable Reference Implementation

ZaidanLab's MIT-licensed WhatsApp OTP Gateway implements these choices with Node.js, Fastify, SQLite, Baileys, an owner dashboard and OpenAPI 3.1. It has a real transport adapter, Docker Compose and a separate single-process systemd deployment template. It does not call an AI model; verification should be deterministic.

Its thirty-three automated backend/adapter checks and browser workflow tests use a substituted WhatsApp transport. They exercise state handling, authentication and API integration, not actual recipient delivery or service uptime. For screenshots, implemented features and the exact validation boundary, see the project case study. The source is available for self-hosting; there is no public shared OTP-sending endpoint advertised here. Sources reviewed on 6 November 2026.