Email OTP in Go: Single-Use Codes, Expiration, and Replay Protection
An email OTP looks simple: generate six digits, send them, and compare what the user types. The security boundary is not the email API, though. It is the server-side challenge lifecycle.
A correct implementation has to make the code unpredictable, expire it quickly, limit guesses, invalidate older challenges when appropriate, and guarantee that a successful code cannot be consumed twice. Those properties are different from TOTP, even though both mechanisms are commonly described as OTP.
Email OTP is not TOTP
Libraries such as github.com/pquerna/otp implement HOTP and TOTP. TOTP derives a code from a shared secret and the current time. The server and authenticator can independently calculate the same value; no code has to be delivered for each authentication attempt.
Email OTP usually works differently. The server creates a random challenge, stores server-side state, and sends the plaintext code through an email provider.
TOTP
shared secret + time
|
v
code derived independently by client and server
Email OTP
server generates random code
|
+--> stores verification state
|
+--> sends code by emailFor that reason, a TOTP package is not required to generate an email verification code. Go’s crypto/rand is sufficient for the randomness primitive.
Generate six digits with crypto/rand
A six-digit decimal code has 1,000,000 possible values, including values with leading zeroes. Generate the number from a cryptographically secure source and format it as exactly six digits.
package otp
import (
"crypto/rand"
"fmt"
"math/big"
)
func GenerateCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}Do not use math/rand for authentication codes. Its purpose is simulation and other non-security uses, not generation of values that must be difficult for an attacker to predict.
Six digits provide limited entropy by design. The defense against online guessing therefore depends heavily on rate limits and attempt limits rather than code length alone.
Store a challenge, not just an email and code
Treat each issuance as an authentication challenge with its own identity and lifecycle.
type Challenge struct {
ID string
UserID string
Purpose string
CodeHash []byte
ExpiresAt time.Time
Attempts int
MaxAttempts int
ConsumedAt *time.Time
}Purpose prevents a code issued for one operation from silently becoming valid for another. Typical values might distinguish login, email verification, password recovery, or a sensitive account action.
A challenge ID also avoids ambiguous verification when several codes have been requested. The browser can carry an opaque challenge ID while the email carries only the short human-entered code.
Store a hash of the code
The email provider needs the plaintext code, but the verification store does not. Hash the code before persisting the challenge.
Because a six-digit code has a tiny search space, an ordinary unsalted hash alone is easy to enumerate if the database is exposed. A keyed HMAC lets the application keep an additional server secret outside the challenge database.
package otp
import (
"crypto/hmac"
"crypto/sha256"
)
func CodeMAC(key []byte, challengeID, code string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(challengeID))
mac.Write([]byte{0})
mac.Write([]byte(code))
return mac.Sum(nil)
}Binding the MAC to the challenge ID prevents the stored verifier for one challenge from being copied directly to another challenge record.
The plaintext code should exist only long enough to hand it to the mail-sending layer. Avoid logging it, placing it in analytics events, or returning it in an API response.
Expiration is checked by the server
The email can say that a code expires in five minutes, but that sentence has no security effect. The server must enforce the timestamp.
if !now.Before(challenge.ExpiresAt) {
return ErrExpired
}Keep the validity period short enough to constrain replay exposure but long enough for ordinary mail delivery. The exact value is a product decision; the important property is that the server owns the deadline and applies it consistently.
Storage systems with TTL support can remove stale records automatically, but TTL cleanup should not be the only expiration check. Deletion may be asynchronous. Verification should still compare the current time with ExpiresAt.
Guess limits belong to the challenge
A six-digit code cannot safely tolerate unlimited verification attempts. Increment an attempt counter for failed submissions and reject the challenge after its budget is exhausted.
if challenge.Attempts >= challenge.MaxAttempts {
return ErrTooManyAttempts
}The increment has to be concurrency-safe. Two simultaneous requests must not both read the same attempt count and then overwrite each other.
Rate limiting should also exist above the individual challenge. Useful boundaries include the account, destination email address, source IP or network, and the endpoint itself. This limits both brute-force verification and abuse of the email provider as a message-sending relay.
Successful verification must consume the code atomically
Checking a code and marking it used in two independent database operations creates a replay race.
This sequence is unsafe:
request A: code is valid
request B: code is valid
request A: mark consumed
request B: mark consumedBoth requests can pass before either writes the consumed state.
Verification should instead perform the decisive state transition atomically. In a relational database, that can be expressed as a conditional update inside a transaction or as one update whose predicate requires an unconsumed, unexpired challenge.
Conceptually:
UPDATE otp_challenges
SET consumed_at = CURRENT_TIMESTAMP
WHERE id = ?
AND consumed_at IS NULL
AND expires_at > CURRENT_TIMESTAMP;The application still needs to verify the submitted code, but the final consume operation must succeed for authentication to succeed. If another request consumed the challenge first, the second request is rejected.
Redis can implement the same property with an atomic operation or Lua script. The storage technology is secondary; the invariant is single consumption.
Resending needs an explicit policy
A resend button introduces another state transition. There are two reasonable designs: resend the same active code, or issue a new challenge and invalidate the previous one.
Issuing a new code is easy to reason about when only one active challenge is allowed per user and purpose:
challenge 1 issued
|
user requests resend
|
challenge 1 invalidated
challenge 2 issued
|
only challenge 2 can succeedWithout an explicit rule, several codes can remain valid at once. That increases the number of guesses an attacker can submit and creates confusing behavior for users who receive delayed emails out of order.
The email provider is a delivery dependency
SMTP, Amazon SES, Alibaba Cloud DirectMail, Mailgun, and similar services do not change the OTP verification model. They transport the message; the application still owns challenge generation and validation.
For OTP traffic, compare providers on more than nominal cost per thousand messages. Delivery latency, bounce handling, API reliability, regional availability, domain authentication, rate limits, and observability affect the authentication path directly.
A provider failure also changes API design. The application should not leave a usable challenge indefinitely after a permanent send failure. At the same time, retrying delivery must not accidentally generate a new valid code on every transport retry.
Separating issuance from delivery makes that boundary clearer:
type Mailer interface {
SendOTP(ctx context.Context, email, code string) error
}The authentication package can own challenge state while the mail adapter owns SES, DirectMail, SMTP, or another provider.
Avoid account enumeration
An endpoint such as POST /auth/email-otp can leak whether an account exists if it returns visibly different responses.
For login or recovery flows, the public response can remain generic:
{
"message": "If the address can receive a code, an email has been sent."
}Internally, the service can still distinguish unknown accounts, blocked accounts, provider failures, and rate-limit events for operational purposes. Public timing and response differences should not become a convenient account-discovery API.
A compact service boundary
The resulting Go API can stay small even when the implementation enforces several invariants.
type Service interface {
Issue(ctx context.Context, userID, email, purpose string) (challengeID string, err error)
Verify(ctx context.Context, challengeID, code string) error
}Issue generates the code, persists its verifier and deadline, and asks the mailer to deliver it. Verify loads the challenge, enforces purpose and state, accounts for attempts, compares the submitted verifier with hmac.Equal, and atomically consumes a successful challenge.
That separation is more useful than coupling OTP logic directly to an SMTP client. Email delivery can change providers without changing the authentication contract.
The security property lives in state transitions
Generating six random digits is the smallest part of email OTP. The meaningful guarantees come from the transitions around those digits:
issued
|
+--> expired
|
+--> locked after failed attempts
|
+--> replaced by a newer challenge
|
+--> consumed exactly onceA Go implementation is robust when every path ends in one of those well-defined states and concurrent requests cannot bypass them. The mail provider can then remain what it should be: a replaceable delivery mechanism rather than the component responsible for authentication correctness.