Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Verifying OTP Input in a Node.js Application for 2FA Login Delivery

Short answer: verify a one-time code against a server-side record bound to one login attempt, expire it quickly, consume it exactly once, and never store the code in plaintext. In a property-management system, keep authe

Short answer: verify a one-time code against a server-side record bound to one login attempt, expire it quickly, consume it exactly once, and never store the code in plaintext. In a property-management system, keep authentication state separate from the generated report and its delivery state. The report may be retried; the OTP may not.

That is the least complicated design that survives a double-click, two application instances racing, a delayed email arriving after a newer code, and a worker retrying an attachment after login has succeeded. Node.js supplies cryptographic primitives. The database must enforce the state transition.

What is the bill actually made of?

The visible bill is usually described as messages and attachment bytes. The system also retains the challenge, delivery records, the report object, and audit evidence. The dominant retention term is often the report object, not the six-digit code. For sizing, 10,000 monthly reports at 2 MB each produce about 20 GB for one monthly batch before replicas, versions, or longer retention are counted. The OTP rows are transient and tiny by comparison.

I make the report immutable and address it by report ID. A retry reuses that object and creates a new delivery attempt; it does not regenerate the file or retain a second copy for each timeout. Keeping the object for 90 days makes an investigation easier. Deleting it after acceptance reduces exposure but makes later reconstruction harder. Choose that boundary from lease, regulatory, and support requirements, then enforce it with a lifecycle job.

The OTP record needs a keyed digest, expiry timestamp, used timestamp, attempt counter, and challenge ID. It does not need the report body or attachment.

How should a Node.js application handle verifying OTP input?

Use a small state machine: issued, accepted, expired, and locked. The transition to accepted must require used_at IS NULL, an expiry in the future, and an attempt count below the limit. A process-local JavaScript variable cannot enforce that when two Node.js workers receive the same request.

A six-digit code has 1,000,000 possible values, so online guessing resistance depends on expiry, throttling, and attempt limits. OWASP recommends rate limiting authentication attempts; the exact limit is application policy. I would make it configuration and test the boundary: five failed guesses, then lock the challenge; a new challenge invalidates the old one.

One practical storage pattern is a keyed digest over challenge ID and code, with the key outside the database. Compare equal-length digests in constant time and document key rotation. The equivalent Node.js primitives are crypto.randomInt, crypto.createHmac, and crypto.timingSafeEqual. Do not log the code, put it in a URL, or return an error that reveals whether an account or challenge exists.

import hashlib
import hmac
import secrets

def make_code():
    return f"{secrets.randbelow(1_000_000):06d}"

def digest(secret, challenge_id, code):
    value = f"{challenge_id}:{code}".encode("utf-8")
    return hmac.new(secret, value, hashlib.sha256).digest()

def matches(expected, supplied):
    return hmac.compare_digest(expected, supplied)

Can one verification request consume the code twice?

This is the race to test before polishing the email template. Two browser tabs can both read used_at = NULL, pass comparison, and create an authenticated state. Use a transaction and a conditional update; allow authentication only when one row changed.

That race is real.

def accept_challenge(db, challenge_id, supplied, now):
    with db.transaction() as tx:
        row = tx.one(
            "SELECT digest, expires_at, used_at, attempts, locked_at "
            "FROM otp_challenges WHERE id = ? FOR UPDATE",
            [challenge_id],
        )
        if row is None or row["locked_at"] is not None:
            return False
        if row["used_at"] is not None or row["expires_at"] <= now:
            return False
        if row["attempts"] >= 5:
            tx.execute("UPDATE otp_challenges SET locked_at = ? WHERE id = ?", [now, challenge_id])
            return False
        if not matches(row["digest"], supplied):
            tx.execute("UPDATE otp_challenges SET attempts = attempts + 1 WHERE id = ?", [challenge_id])
            return False
        changed = tx.execute(
            "UPDATE otp_challenges SET used_at = ? "
            "WHERE id = ? AND used_at IS NULL AND expires_at > ?",
            [now, challenge_id, now],
        ).rowcount
        return changed == 1

Test parallel submissions, a retry after a network timeout, an expiry at the exact boundary, and a new challenge issued while the old one is verified. Also test the query builder's transaction scope; a convenient abstraction can quietly move one statement outside the lock. This is where a pleasant local test can mislead the team: a single request passes, but two requests on separate workers need the database to serialize the consume operation, and a timeout can leave the caller unable to distinguish an accepted delivery from a request that never reached the worker. That ambiguity belongs in the retry policy, not in a hidden catch block.

How should authentication and report delivery meet?

Meet through authorization, not shared mutable status. After verification, issue a session or short-lived authorization result containing the account, tenant, and permitted report ID. The report worker checks that permission when accepting a delivery job. It does not recheck the OTP, and a provider response does not prove that authentication succeeded.

Use an idempotency key such as (report_id, recipient_id, delivery_revision). Record queued, sending, accepted, and failed with timestamps and an opaque message reference. A timeout after submission is ambiguous: retrying may duplicate the email, while refusing to retry may lose it. Define a duplicate policy, bounded retries, and reconciliation against the delivery result. No database flag turns an ambiguous network call into certainty.

Generate the report once, checksum its bytes, store the checksum with metadata, and attach the same immutable bytes on each permitted retry. Logs can carry report ID, tenant ID, attempt number, and outcome. They should not carry the attachment, OTP, session token, or full recipient address.

Track challenge issuance, verification failures by reason class, lockouts, the age of the oldest queued report, duplicate-key suppressions, checksum mismatches, and elapsed time from accepted authentication to delivery. A low failure count can still hide a queue that stopped consuming.

The durable design is intentionally plain: secure randomness, a short-lived server-side challenge, one atomic consume operation, an authorization boundary, one immutable report object, and a delivery record that admits ambiguity. Retain the report long enough for business and security investigation, but remove OTP material after expiry and audit access to the report itself. The limitation is that email OTP still depends on a mailbox and can be delayed or intercepted. That trade-off makes it a poor fit for a high-assurance account unless the threat model accepts the channel; a stronger authenticator changes the enrollment and recovery workflow as well as the verification handler.

The hard part is deciding what remains true when requests arrive twice, workers restart, and a message is accepted outside your transaction. Treat those facts as database invariants, then keep the Node.js handlers thin enough that reviewers can see them.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.