Hard backend java

How would you design a secure OTP platform?

The 30 second answer

I first separate delivered verification codes from authenticator-generated time-based passwords. For a delivered code, I create a short-lived challenge bound to a user, purpose and login attempt. Verification atomically checks expiry, counts failed attempts and consumes a match once. I rate-limit both sending and guessing across instances, protect the code in storage and logs, and handle delayed delivery and retries without issuing extra valid challenges.

Scope and terminology. Ask whether the task is login authentication, password recovery, phone/address confirmation, or payment approval. These are not interchangeable privileges. NIST calls a verifier-generated code sent to a separate device an out-of-band secret, while an authenticator app generates OTPs using a shared key and nonce/time. For TOTP use a vetted RFC 6238 implementation and protect the long-lived seed; do not implement TOTP by generating a new random SMS code every 30 seconds. The design below is for delivered random challenges, not a complete TOTP implementation.

Proposed architecture. A Spring Boot challenge API authenticates the initiating flow, applies abuse policies, writes challenge state and a delivery job, and returns a generic acknowledgment plus an opaque challenge id. A bounded worker queue handles provider latency; verification remains independent of delivery latency. Use a transactional outbox if challenge creation and delivery enqueue must survive a crash together. Provider retry does not generate a new challenge or extend its expiry. Delivery acknowledgment is not proof of possession. Avoid account enumeration through differing response messages, statuses or readily distinguishable timings.

State and generation. Persist Challenge(id, subject, purpose, flowId, digest, expiresAt, status, generation), with a separate account/authenticator-level failure counter and send-throttle state. Generate codes with a cryptographically strong generator, not Math.random, a timestamp or a sequential counter. For illustration, six uniformly selected decimal digits have one million possibilities; preserve leading zeros and compensate for low entropy with strict online limits. Set a short validity period and enforce it at verification, not merely by a background deletion job. NIST out-of-band authentication requires at least six digits and completion within ten minutes; those are constraints for that authenticator category, not a universal TTL for all verification workflows. A shorter application expiry is a policy choice.

Store a keyed HMAC over an unambiguous encoding of challenge id, subject, purpose, flow and code, with the HMAC key held separately from the data store. A plain fast hash of a six-digit code permits cheap offline enumeration after a database leak. Constant-time digest comparison avoids an avoidable comparison oracle, but does not replace throttling. Encrypt any temporary delivery payload, limit access and retention, and never log the raw code, seed or authentication token.

Atomic verify-and-consume. Proposed local-transaction algorithm:

BEGIN
lock account/authenticator policy row
lock challenge row
reject if flow/purpose/subject binding mismatches, expired,
          not ACTIVE, superseded, or account budget exhausted
compute candidate digest and compare in constant time
if mismatch: increment durable account failure counter; COMMIT failure
if match: set challenge status = CONSUMED;
          record the flow's successful verification; COMMIT success

PostgreSQL row locks serialize competing verifiers on the same challenge. Thus two simultaneous correct submissions cannot both transition ACTIVE to CONSUMED. Increment failure counts even when individual challenges are replaced, and do not roll the increment back merely because the application returns an authentication error. Resend must not reset the account-level guessing budget. Explicitly decide whether resend redelivers the same challenge or supersedes the old one; if superseding, serialize generation change and invalidate the previous challenge. A delayed older message must never revive an obsolete code.

Bind successful verification to the intended flow and purpose: an address-confirmation success must not become a generic login token. Commit consumption before granting the privilege; handle a lost response using a carefully bound flow result rather than making the code reusable. Rate-limit per account/destination and risk source, as well as per challenge, so creating many new challenges cannot bypass the budget. Add delivery spend limits and destination cooldowns to prevent SMS bombing. In a shared Redis implementation, the entire check/update/consume operation needs an atomic script or equivalent transaction; Redis expiry alone is not one-time consumption. For security-critical replay prevention, evaluate persistence/failover guarantees explicitly rather than assuming acknowledged in-memory writes cannot be lost.

Channel risks. NIST prohibits email as an out-of-band authentication channel in its model, while distinguishing email-address confirmation and recovery codes from that prohibition. PSTN/SMS authentication is restricted and needs alternatives and risk assessment. Manually entered OTPs are not phishing-resistant: an attacker can relay a valid code in real time. Offer phishing-resistant authenticators for higher-risk login rather than advertising an SMS platform as phishing-proof.

Tests and operations. Race two correct verifications; exactly one consumes the challenge. Try a correct expired code, wrong purpose, superseded generation, exhausted budget after resend, provider duplicate delivery and a crash before/after commit. Monitor delivery latency, verification conversion, failed-attempt rate, resend rate, spend and queue age without recording secrets. Under provider outage use bounded retry and clear retry guidance, not unlimited send loops or fail-open verification.

Sources (5)
  1. NIST SP 800-63B-4: Out-of-band authenticators/verifiers, OTP and rate limiting (§3.1.3–3.1.5, §3.2.2, §3.2.5)
  2. RFC 6238: TOTP original specification
  3. Java 21 SecureRandom: cryptographically strong generator
  4. PostgreSQL 18: Row-level locking
  5. Chris Richardson: Transactional outbox