JWT-Based OTP Authentication Theory
Overview
This document outlines a JWT-based OTP authentication mechanism for user registration and password reset. The approach ensures security without storing OTPs in a database by dynamically generating secrets for JWT signing.
Registration Process
Step 1: Request OTP
- The client submits user details (e.g., email, username, password) to the server.
- The server generates a random OTP.
- The server signs a JWT token with a secret key:
- Secret Key Format:
server-secret + OTP
- The token contains the user details.
- Secret Key Format:
- The server sends the OTP to the user via email/SMS and returns the JWT token to the client.
- The client stores the token temporarily.
Step 2: Verify OTP and Complete Registration
- The client submits the JWT token and OTP to the server.
- The server verifies the JWT token using the secret key (
server-secret + OTP
). - If verification succeeds:
- The server checks if the email/username already exists in the database.
- [If] the email/username does not exist, the user is created based on the user details from the token.
- [Else], an error message is returned.
- If verification fails, an error message is returned.
Security Enhancements
- To prevent duplicate account creation with the same token and OTP:
- On the first step of registration, the server checks if the email/username already exists.
- On the second step, the server can also check if the email/username already exists. This can be optimized by using a unique email/username constraint in the database.
Password Reset Process
Step 1: Request Password Reset Token
- The client submits user identification (email/username/ID) to the server.
- The server generates a random OTP.
- The server signs a JWT token with a secret key:
- Secret Key Format:
server-secret + OTP
- The token contains the user identifier.
- Secret Key Format:
- The server sends the OTP to the user and returns the JWT token to the client.
- The client stores the token temporarily.
Step 2: Verify OTP and Reset Password
- The client submits the JWT token and OTP to the server along with the new password.
- The server verifies the JWT token using
server-secret + OTP
. - If verification succeeds:
- The server checks if the password has already been reset using
passwordChangedAt
. - [If]
passwordChangedAt
is after the token creation date, the request is rejected. - [Else], the password is updated, and
passwordChangedAt
is set to the current timestamp.
- The server checks if the password has already been reset using
- If verification fails, an error message is returned.
Security Enhancements
- Prevent repeated password resets with the same token and OTP:
- Save
passwordChangedAt
in the user record. - [If] a reset attempt is made with an old token, the server checks if
passwordChangedAt
is after the token creation date. Then the request needs to be rejected.
- Save
Example Implementation
Install the required packages:
npm install express jsonwebtoken
import express from 'express'
import jwt from 'jsonwebtoken'
const app = express()
app.use(express.json())
const SERVER_REGISTER_SECRET = 'your-server-register-secret'
const SERVER_RESET_PASSWORD_SECRET = 'your-server-reset-password-secret'
// Step 1: Request OTP
app.post('/register/request-otp', (req, res) => {
const { email, password } = req.body as RegisterTokenRequestBody
// Check if user already exists
if (users.find((user) => user.email === email)) {
res.status(400).json({ error: 'User already exists' })
return
}
// Generate OTP and sign the token
const otp = generateOTP()
const secretKey = `${SERVER_REGISTER_SECRET}-${otp}`
const token = jwt.sign({ email, password }, secretKey, { expiresIn: '5m' })
console.log(`OTP for ${email}:`, otp) // Simulate sending OTP
res.json({ token })
})
// Step 2: Verify OTP & Register
app.post('/register/verify', (req, res) => {
const { token, otp } = req.body as RegisterVerifyRequestBody
const secretKey = `${SERVER_REGISTER_SECRET}-${otp}`
try {
// Verify the token
const decoded = jwt.verify(token, secretKey) as RegisterJwtPayload
// Check if user already exists
if (users.find((user) => user.email === decoded.email)) {
res.status(400).json({ error: 'User already exists' })
return
}
// Insert user into the database
users.push({
email: decoded.email,
password: decoded.password,
passwordChangedAt: new Date(),
})
res.json({ message: 'User registered successfully' })
} catch (err) {
res.status(400).json({ error: 'Invalid OTP or token' })
}
})
// Step 1: Request Password Reset Token
app.post('/password-reset/request', (req, res) => {
const { email } = req.body as ResetPasswordTokenRequestBody
// Check if user exists
if (!users.find((user) => user.email === email)) {
res.status(400).json({ error: 'User not found' })
return
}
// Generate OTP and sign the token
const otp = generateOTP()
const secretKey = `${SERVER_RESET_PASSWORD_SECRET}-${otp}`
const token = jwt.sign({ email }, secretKey, { expiresIn: '5m' })
console.log(`OTP for password reset ${email}:`, otp) // Simulate sending OTP
res.json({ token })
})
// Step 2: Verify OTP & Reset Password
app.post('/password-reset/verify', (req, res) => {
const { token, otp, newPassword } = req.body as ResetPasswordVerifyRequestBody
const secretKey = `${SERVER_RESET_PASSWORD_SECRET}-${otp}`
try {
// Verify the token
const decoded = jwt.verify(token, secretKey) as ResetPasswordJwtPayload
// Check if user exists
const user = users.find((user) => user.email === decoded.email)
if (!user) {
res.status(400).json({ error: 'User not found' })
return
}
if (!decoded.iat) {
throw new Error('Token issued date not found')
}
if (
user.passwordChangedAt &&
decoded.iat < user.passwordChangedAt.getTime()
) {
res
.status(400)
.json({ error: 'Token issued before last password change' })
return
}
// Update password on the database
user.password = newPassword
user.passwordChangedAt = new Date()
res.json({ message: 'Password reset successful' })
} catch (err) {
res.status(400).json({ error: 'Invalid OTP or token' })
}
})
app.listen(3000, () => console.log('Server running on port 3000'))
// ---------- Utilities and Databases --- Can be a separate file
// User model
type User = {
email: string
password: string
passwordChangedAt: Date
}
// Simulate database
const users: User[] = []
function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString()
}
// ---------- Necessary Types --- Can be a separate file
type RegisterJwtPayload = jwt.JwtPayload & {
email: string
password: string
}
type ResetPasswordJwtPayload = jwt.JwtPayload & {
email: string
}
type RegisterTokenRequestBody = {
email: string
password: string
}
type RegisterVerifyRequestBody = {
token: string
otp: string
}
type ResetPasswordTokenRequestBody = {
email: string
}
type ResetPasswordVerifyRequestBody = {
token: string
otp: string
newPassword: string
}
Best Practices
- Use a dynamic secret key for JWT signing:
- Registration:
${server-secret}-REGISTER-${OTP}
- Password Reset:
${server-register-secret}-${OTP}
- Registration:
- Ensure unique tracking of user actions (e.g., email uniqueness,
passwordChangedAt
timestamps). - Use small OTP expiry times to reduce security risks. eg. 5 minutes.
- Implement rate limiting to prevent brute-force attacks.
Use an HMAC function (e.g., SHA256) instead of directly appending OTP to the secret key.
import crypto from 'crypto' function getSecretKey(baseSecret: string, otp: string): string { return crypto.createHmac('sha256', baseSecret).update(otp).digest('hex') } const secretKey = getSecretKey(SERVER_REGISTER_SECRET, OTP)
This approach secures registration and password reset flows while avoiding persistent OTP storage.
Limitations
- Handle Token Expiration: If a registration/reset password token is created multiple times, previous tokens and OTPs will still be valid until they expire.
- Can be more complex for crazy tasks: This method can be difficult to integrate with other OTP management systems. Invalidating previous tokens would require additional tracking mechanisms like
lastAccessedAt
, which can add complexity. - No Centralized OTP Tracking: Since OTPs are not stored in a database, there is no way to manually revoke or track OTP usage.
- Limited Token Expiry Control: JWTs are stateless, meaning once issued, they cannot be revoked unless additional tracking (like blacklistedTokens or lastAccessedAt) is implemented.
- Potential Synchronization Issues: If multiple OTP requests are made in quick succession, users might enter an older OTP, leading to failed verification. Even thought this is impractical, it is still a possibility.
- No Multi-Factor Authentication (MFA): This approach only relies on OTP for verification, which might not be sufficient for extremely high-security applications.