Comparing OTP Verification Methods: Database, Cache, and JWT-Based Approaches

One-time passwords (OTPs) are short-lived codes used to verify a user’s identity in flows like password reset, login, or multi-factor authentication. An OTP is “a random password that is valid for a short period of time (usually 30 or 60 seconds)…usually a numeric code of 6 or 8 digits” (Identity Management and Two-Factor Authentication Using One-Time Passwords). Because they change constantly and expire quickly, OTPs ensure that only someone with access to the user’s email or phone (something you have) can complete a sensitive action. In practice, a server generates an OTP and sends it to the user (via email/SMS), then the user submits it back for verification within a short window. This prevents replay attacks and confirms the user controls the delivery channel (e.g. their email). Using OTPs adds an extra security layer: even if a password is compromised, an attacker still needs the one-time code. They are especially useful for: Email/Phone Verification: Confirming that a user can receive messages at a given address or number. Two-Factor Authentication: As a second factor (“something you have”) alongside a password. Transaction/Action Confirmation: Verifying sensitive operations (e.g. bank transfers). OTPs are inherently time-limited and one-use, so systems usually expire or delete them soon after generation or first use (Identity Management and Two-Factor Authentication Using One-Time Passwords). Traditional OTP Storage Methods Storing OTPs in a Database One straightforward method is to save each OTP in a database table. For example, when a user requests an OTP, the server generates a code and inserts a record like (user_id, otp_code, expires_at) into a relational or NoSQL database. To verify, the server queries the table to check that the submitted OTP matches the stored code for that user and is not expired. Upon success (or expiry), the entry is deleted or marked used. Implementation: Easy if you already use a database. You need a table/collection for OTPs and logic to insert, query, and delete records. Some databases support Time-To-Live (TTL) indexes so expired OTP records auto-delete. For example, MongoDB’s TTL feature “make[s] it possible to store data… and have MongoDB automatically remove data after a specified number of seconds” (Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs), which is ideal since OTPs are only needed briefly. Scalability: Works for low to moderate load, but every OTP requires a round-trip to the database. Under heavy traffic, this can become a bottleneck or require scaling the database. Security: The server must secure the OTP table. A breached database could leak valid codes (though they expire quickly). You must also protect against SQL/NoSQL injection. Statefulness: This approach is stateful – the server retains OTP state. Each verification requires looking up the stored code. Performance: Typically slower than in-memory caching. Database writes/reads incur I/O and transaction overhead. Tip: Use a database TTL feature or a cron job to clean up old OTP entries, since they "only need to persist for a limited period of time” (Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs). Otherwise, unused or expired OTPs will clutter the database. Storing OTPs in an In-Memory Cache (e.g. Redis) Another common pattern is to use an in-memory key-value store like Redis, which is well-suited for ephemeral data. In this method, the server generates an OTP and uses a Redis SET command to store it with a short expiration. For example, many implementations use the user’s email or ID as the Redis key and the OTP as the value (often with a TTL of 5–15 minutes): // Pseudocode using Redis const otp = generateOtp(); const key = `otp:${userEmail}`; // e.g. "otp:alice@example.com" redisClient.set(key, otp, 'EX', 300); // expire in 300 seconds (5 mins) To verify, the server does redisClient.get(key) and compares it to the submitted code. If they match, the server typically deletes the key (DEL key) to prevent reuse. As one guide notes, after a match “the OTP will be deleted using client.del(key) method to avoid any vulnerabilities” (Implement OTP Verification using Redis and Node.js - DEV Community). If there’s no match, the server rejects the attempt. Implementation: Slightly more work to set up Redis, but storing and retrieving keys is straightforward (e.g. SET key value EX seconds). Many client libraries make this easy. Scalability: High. Redis is in-memory and optimized for fast reads/writes. It can handle a large throughput of requests, so it scales well under heavy load. Security: Similar risks as a database: a compromise of Redis could expose active OTPs. However, OTPs expire automatically (and can be deleted on use), limiting the window of exposure. Always secure your Redis instance (no open public access). Statefulness: This is also stateful. The server mu

Apr 29, 2025 - 14:14
 0
Comparing OTP Verification Methods: Database, Cache, and JWT-Based Approaches

One-time passwords (OTPs) are short-lived codes used to verify a user’s identity in flows like password reset, login, or multi-factor authentication. An OTP is “a random password that is valid for a short period of time (usually 30 or 60 seconds)…usually a numeric code of 6 or 8 digits” (Identity Management and Two-Factor Authentication Using One-Time Passwords). Because they change constantly and expire quickly, OTPs ensure that only someone with access to the user’s email or phone (something you have) can complete a sensitive action. In practice, a server generates an OTP and sends it to the user (via email/SMS), then the user submits it back for verification within a short window. This prevents replay attacks and confirms the user controls the delivery channel (e.g. their email).

Using OTPs adds an extra security layer: even if a password is compromised, an attacker still needs the one-time code. They are especially useful for:

  • Email/Phone Verification: Confirming that a user can receive messages at a given address or number.
  • Two-Factor Authentication: As a second factor (“something you have”) alongside a password.
  • Transaction/Action Confirmation: Verifying sensitive operations (e.g. bank transfers).

OTPs are inherently time-limited and one-use, so systems usually expire or delete them soon after generation or first use (Identity Management and Two-Factor Authentication Using One-Time Passwords).

Traditional OTP Storage Methods

Storing OTPs in a Database

One straightforward method is to save each OTP in a database table. For example, when a user requests an OTP, the server generates a code and inserts a record like (user_id, otp_code, expires_at) into a relational or NoSQL database. To verify, the server queries the table to check that the submitted OTP matches the stored code for that user and is not expired. Upon success (or expiry), the entry is deleted or marked used.

  • Implementation: Easy if you already use a database. You need a table/collection for OTPs and logic to insert, query, and delete records. Some databases support Time-To-Live (TTL) indexes so expired OTP records auto-delete. For example, MongoDB’s TTL feature “make[s] it possible to store data… and have MongoDB automatically remove data after a specified number of seconds” (Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs), which is ideal since OTPs are only needed briefly.
  • Scalability: Works for low to moderate load, but every OTP requires a round-trip to the database. Under heavy traffic, this can become a bottleneck or require scaling the database.
  • Security: The server must secure the OTP table. A breached database could leak valid codes (though they expire quickly). You must also protect against SQL/NoSQL injection.
  • Statefulness: This approach is stateful – the server retains OTP state. Each verification requires looking up the stored code.
  • Performance: Typically slower than in-memory caching. Database writes/reads incur I/O and transaction overhead.

Tip: Use a database TTL feature or a cron job to clean up old OTP entries, since they "only need to persist for a limited period of time” (Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs). Otherwise, unused or expired OTPs will clutter the database.

Storing OTPs in an In-Memory Cache (e.g. Redis)

Another common pattern is to use an in-memory key-value store like Redis, which is well-suited for ephemeral data. In this method, the server generates an OTP and uses a Redis SET command to store it with a short expiration. For example, many implementations use the user’s email or ID as the Redis key and the OTP as the value (often with a TTL of 5–15 minutes):

// Pseudocode using Redis
const otp = generateOtp();
const key = `otp:${userEmail}`;   // e.g. "otp:alice@example.com"
redisClient.set(key, otp, 'EX', 300);  // expire in 300 seconds (5 mins)

To verify, the server does redisClient.get(key) and compares it to the submitted code. If they match, the server typically deletes the key (DEL key) to prevent reuse. As one guide notes, after a match “the OTP will be deleted using client.del(key) method to avoid any vulnerabilities” (Implement OTP Verification using Redis and Node.js - DEV Community). If there’s no match, the server rejects the attempt.

  • Implementation: Slightly more work to set up Redis, but storing and retrieving keys is straightforward (e.g. SET key value EX seconds). Many client libraries make this easy.
  • Scalability: High. Redis is in-memory and optimized for fast reads/writes. It can handle a large throughput of requests, so it scales well under heavy load.
  • Security: Similar risks as a database: a compromise of Redis could expose active OTPs. However, OTPs expire automatically (and can be deleted on use), limiting the window of exposure. Always secure your Redis instance (no open public access).
  • Statefulness: This is also stateful. The server must query Redis to verify each OTP. However, because keys auto-expire, the state is short-lived.
  • Performance: Very fast. Memory lookup is orders of magnitude faster than disk-based databases. This means quicker OTP verification and less latency for users.

Example in practice: a Node.js tutorial generates a 4-digit OTP, then calls client.set(email, otp) using the user’s email as the key (Implement OTP Verification using Redis and Node.js - DEV Community). During verification it does client.get(email) and, on a match, calls client.del(email) to remove it (Implement OTP Verification using Redis and Node.js - DEV Community). This demonstrates how Redis can store one-time codes simply and clean up after use.

JWT-Encrypted OTP Method

How It Works

A newer approach avoids server-side storage altogether by embedding the OTP inside a signed (and optionally encrypted) JSON Web Token (JWT). In this scheme, when the user requests an OTP, the server:

  1. Generates an OTP code.
  2. Creates a JWT (or JWE) whose payload includes the OTP and identifying info. For example, the payload might be { "user_id": 123, "email": "alice@example.com", "otp": 847135, "exp": }.
  3. Signs (and encrypts) the token. The token might be a JWE using symmetric encryption (e.g. AES) so that only the server can decrypt it, or a signed JWS where the server trusts the payload signature.
  4. Sends the OTP to the user via email/SMS and returns the token to the frontend (often as a JSON response). The user never sees the token directly, only the OTP code.

This flow is illustrated in one example: the server “create[s] a payload… pass it to the create_token function. The payload contains the user id, user email, otp itself and the expiry. After successfully getting the token, we will send the otp to the user’s email and then send a json response containing the token to the frontend again” (OTP Verification in Django REST Framework using JWT and Cryptography | GeeksforGeeks). In code, it might look like:

otp = random.randint(100000, 999999)
payload = {
    "user_id": user.id,
    "email": user.email,
    "otp": str(otp),
    "exp": now + datetime.timedelta(minutes=5)
}
token = create_token(payload)   # encrypts/signs payload
send_email(user.email, f"Your OTP is {otp}")
return {"token": token}

(Excerpted from a Django+JWT example (OTP Verification in Django REST Framework using JWT and Cryptography | GeeksforGeeks).)

The token is stored client-side (e.g. in browser memory) or sent as a hidden field. Later, when the user submits the OTP code back for verification, the client includes this token along with the OTP. The server then decrypts/verifies the JWT, extracts the payload, and checks that the embedded otp matches the submitted code and that the token is not expired. No lookup is needed because the “truth” about the OTP is carried in the token itself. If valid, the server accepts the verification.

Example Flow

  1. User enters email: User requests an OTP (e.g. by clicking “send code” for password reset).
  2. Server issues OTP and token: Backend generates otp=123456, creates a JWT containing {"email":"user@example.com","otp":"123456","exp":}, encrypts/signs it, emails the code to the user, and returns the JWT to the frontend (OTP Verification in Django REST Framework using JWT and Cryptography | GeeksforGeeks).
  3. User submits OTP: User checks their email, finds code “123456”, and enters it into the form. The frontend sends { otp: "123456", token: "" } to the server.
  4. Server verifies: The server decrypts/validates the JWT. It retrieves the payload’s otp and compares to the submitted code. If they match and the token is still within its expiration window, verification succeeds. Otherwise it fails.

This method removes the need for any OTP storage on the server. The server only needs to manage JWT signing keys or encryption keys, not a database or cache of active codes.

Comparing the Methods

Criteria Database Cache (Redis) JWT-Encrypted OTP
Ease of Implementation Simple for basic apps; requires DB schema and cleanup logic. Easy if Redis is already used; need TTL handling. Moderate: requires JWT library and cryptography, more code.
Scalability Can become a bottleneck under high load (DB writes/reads). Highly scalable (in-memory, very fast; can cluster Redis). Very scalable (stateless: no server-side lookups).
Security Relies on DB security. Compromise could expose OTPs (though short-lived). In-memory store; compromise exposes codes but only briefly. Uses cryptographic tokens. Payload is encrypted/signed, so even if a token leaks, key required to read it.
Statelessness Stateful (server must store and look up each OTP). Stateful (server stores OTPs in Redis, though ephemeral). Stateless (server does not store OTPs; data is in the token).
Performance Slower (disk I/O, more latency per OTP). Fast (memory access; low latency). Very fast for lookups (no DB access); has CPU cost for crypto.

Table: Key differences between OTP verification methods.

Advantages and Disadvantages of JWT-Based OTP

Advantages:

  • Statelessness: The server doesn’t need to remember any OTPs. This is excellent for distributed or serverless architectures, since any instance can verify the token without shared storage.
  • Built-in Expiry: The token’s exp field enforces timeout automatically (OTP Verification in Django REST Framework using JWT and Cryptography | GeeksforGeeks). Once expired, the JWT (and its OTP) are invalid.
  • No Cleanup Needed: Unlike a DB or cache, there’s no need for background cleanup of old OTPs – they simply expire.
  • Compactness: All necessary data (user, OTP, timestamp) travels in a compact token. Useful for microservices or when you want to avoid a session store.
  • Security (with encryption): If you encrypt the JWT (JWE) or include only non-sensitive info, the OTP is hidden from the client and eavesdroppers. Only the server can read it using its secret key (encryption - Should jwt web token be encrypted? - Stack Overflow).

Disadvantages:

  • Complexity: You must correctly implement JWT signing and encryption. Bugs can undermine security.
  • Token Theft Risk: If an attacker steals a token before it expires, they could read its OTP or reuse it (especially if just signed and not encrypted). As noted, a JWT’s payload “is still visible to anyone that gets hold of it. If you have sensitive data in that payload, then encrypting it might be a good idea” (encryption - Should jwt web token be encrypted? - Stack Overflow). Thus you should use JWE or otherwise protect it.
  • Inability to Revoke: Once issued, a token is valid until expiry. There’s no easy way to revoke a JWT-embedded OTP early (short of adding server-side revocation lists).
  • Reliance on Client: The token lives client-side (e.g. in browser memory). Poor handling (leaks, long storage) can introduce risk.
  • Crypto Overhead: Each verification requires decrypting or verifying the JWT, which is heavier than a simple cache lookup. In high-volume scenarios, this CPU cost matters (though for short tokens it’s usually minor).

In summary, JWT OTP is powerful for stateless systems but requires careful handling of keys and tokens. Without encryption, any data in the JWT (including the OTP) could be exposed (encryption - Should jwt web token be encrypted? - Stack Overflow), so strong encryption and HTTPS transport are musts.

Security Considerations

  • Token Leakage: Always use HTTPS so that the token cannot be intercepted in transit. If a JWT is stolen (e.g. via XSS, logs, or a malicious intermediary), an attacker could steal the OTP. Ensure tokens have very short lifetimes (minutes).
  • Token Expiration: Set the exp (expiry) claim to a short duration. Best practice is “minutes or hours at maximum” (JWT Security Best Practices | Curity), not days. For OTPs, a typical expiry is 5–15 minutes. [24] advises avoiding long-lived tokens.
  • Signature & Encryption Strength: Use strong algorithms. For JWS tokens, use HMAC SHA-256 or an RSA/ECDSA pair (e.g. RS256) with a strong key. For JWE, use AES with a robust mode (e.g. AES-GCM or Fernet’s AES-CBC+HMAC). The Python cryptography.Fernet implementation (often used for such tasks) uses AES-128-CBC with HMAC-SHA256 to ensure both confidentiality and integrity (What is Fernet and when should you use it?) (What is Fernet and when should you use it?). In any case, keep your signing/encryption keys secure (rotate if needed).
  • One-Time Guarantee: Even though it’s a JWT, treat the OTP inside as one-use. After a successful verification, you should consider the token invalid. For stateless JWTs, this typically means simply allowing it to expire and not issuing a new one; do not reuse tokens.
  • Limiting Sensitive Data: Only include necessary claims. Apart from email or user_id (for lookup), avoid putting extra sensitive information in the token. [20] cautions that a JWT’s payload is readable if the token is obtained (encryption - Should jwt web token be encrypted? - Stack Overflow), so do not put passwords or PII in it. The OTP itself is sensitive, so ensure it is either not visible in plaintext (use JWE) or only valid very briefly.

Performance and Scalability Impact

  • Database: Performance depends on your DB. At low scale, it’s fine, but if you must handle thousands of OTP requests per second, DB writes/reads become expensive. Databases also incur disk I/O, transactional overhead, and locking. In contrast, in-memory approaches (Redis or JWT) avoid most of that.
  • Redis: Extremely fast and can handle high QPS. A Redis SET/GET takes sub-millisecond. Even on modest hardware, Redis can do millions of operations per second, so it scales well for large user bases. Its in-memory nature means lookups are very cheap. To scale further, Redis can be clustered. (However, Redis still requires network calls to a central cache unless you run local instances per app.)
  • JWT Method: Verification is very fast because it doesn’t involve network calls – the server only does CPU work to verify/decrypt the token. This typically takes a fraction of a millisecond. The trade-off is CPU cost: cryptographic operations (HMAC or AES) require more computation than a simple cache lookup. In practice, for moderate traffic, the CPU overhead of JWT is small compared to network/database costs. Another advantage is truly horizontal scaling: since there’s no shared OTP store, you can spin up many stateless server instances behind a load balancer, and they all use the same signing key to verify any token.
  • Latency: JWT has slightly higher latency on each verification due to crypto, but that is usually less than the latency of a DB query or an external API call. Also, JWT avoids database/network round-trips altogether for OTP verification.

In summary, for high scale, the JWT approach can outperform a database scheme because it removes centralized storage bottlenecks. Redis already offers high performance, so JWT mainly helps by eliminating the cache lookup entirely at verification time, at the cost of encryption work.

When to Use Which Method

  • Database Approach: Simple to implement when starting out or for very small projects. If you already have a user database and very low OTP traffic, it might suffice. Also useful if you want easy auditing of issued OTPs, or if you already employ TTL features (e.g. MongoDB TTL indexes (Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs)). However, be prepared to shift to something else as usage grows.
  • Redis (In-Memory Cache): A great choice for most applications once you need speed and efficiency. Use Redis if you already use it for other caching or session data. It’s especially good if you want auto-expiry (Redis SET with EX or SETEX) and lightning-fast reads. It’s still simple to implement (just replace DB calls with Redis commands) but dramatically better at scale.
  • JWT-Encrypted OTP: Best when you need a fully stateless server or you’re in a microservices environment. If your application is distributed across many instances and you want to avoid coordinating shared state, JWT is ideal. It’s also handy when you want to avoid any storage (e.g. serverless functions). Choose this if you have security expertise and need maximum scalability. Keep in mind it’s more complex – so use it when its benefits (statelessness, no DB/cache dependency) outweigh the implementation cost.

Key advice: For beginners or small systems, start with Redis. It’s a good balance of simplicity and performance. Only adopt JWT-OTP if you specifically need stateless design or plan for very large scale. Always ensure whichever method you use has proper expiration and one-use enforcement for the OTPs.

Conclusion

OTPs are an essential security tool for verifying users via short-lived codes. Traditionally, OTPs are stored server-side (in a database or cache) and checked on submission. A newer JWT-based method encodes the OTP in a signed/encrypted token, removing the need for server storage. Each approach has trade-offs:

  • Database storage is simple but stateful and slower.
  • Redis (cache) storage is stateful but very fast and handles expiration easily.
  • JWT-encrypted tokens are stateless and scalable but require careful implementation and key management.

In all cases, short expiry times and secure transmission are critical. Understanding these methods lets you pick the right strategy: e.g. use Redis for speedy, ephemeral OTP storage, or JWT for a stateless architecture. By weighing ease of implementation, security, and scalability (as summarized in the table above), you can choose the optimal OTP verification method for your application.

Sources: Practical guides and examples of OTP systems, Redis usage, and JWT security practices
(Identity Management and Two-Factor Authentication Using One-Time Passwords)

(Implement OTP Verification using Redis and Node.js - DEV Community)

(Implement OTP Verification using Redis and Node.js - DEV Community)

(OTP Verification in Django REST Framework using JWT and Cryptography | GeeksforGeeks) (encryption - Should jwt web token be encrypted? - Stack Overflow)

(JWT Security Best Practices | Curity)

(Expire Data from Collections by Setting TTL - Database Manual v8.0 - MongoDB Docs).