August 21, 2025
User authentication is the cornerstone of any secure web application, and password reset functionality is often one of the most critical—and vulnerable—features developers implement. Today, I'm excited to share the journey of building a modern, secure password reset system for our Feng Shui 2 RPG campaign management platform, a comprehensive solution that prioritizes both security and user experience.
The modernized password reset system is a full-stack feature that transforms how users recover their accounts when they forget their passwords. Built with Rails 8.0 on the backend and Next.js 15 on the frontend, this system implements industry-standard security practices while providing an intuitive, mobile-responsive user interface.
The password reset flow follows a secure, user-friendly process:
Users visit the /forgot-password page and enter their email address. The frontend validates the email format using RFC 5322 compliance before sending the request:
// Frontend validation with real-time feedback
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export default function ForgotPasswordForm({
onSubmit,
loading = false,
error = null,
success = false
}: ForgotPasswordFormProps) {
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateEmail(email)) {
setEmailError('Please enter a valid email address')
return
}
await onSubmit(email)
}
// Material UI form with professional styling...
}
The Rails backend processes the request through our enhanced Users::PasswordsController with multiple security layers:
class Users::PasswordsController < Devise::PasswordsController
before_action :rate_limit_password_resets, only: [:create]
before_action :authenticate_rate_limit_bypass, only: [:create]
def create
email = resource_params[:email]&.downcase&.strip
# Always return success to prevent email enumeration
if email.present?
resource = User.find_by(email: email)
if resource&.persisted?
resource.send_reset_password_instructions
Rails.logger.info "Password reset sent for #{email}"
else
Rails.logger.info "Password reset attempted for non-existent email: #{email}"
end
end
render json: {
message: "If your email address exists in our database, you will receive password reset instructions shortly."
}, status: :ok
end
private
def rate_limit_password_resets
email = resource_params[:email]&.downcase&.strip
return unless email.present?
email_key = "password_reset_rate_limit:email:#{email}"
ip_key = "password_reset_rate_limit:ip:#{request.remote_ip}"
email_count = Rails.cache.read(email_key) || 0
ip_count = Rails.cache.read(ip_key) || 0
if email_count >= 3 || ip_count >= 5
render json: {
error: "Too many password reset attempts. Please wait before trying again.",
retry_after: 3600
}, status: :too_many_requests and return
end
Rails.cache.write(email_key, email_count + 1, expires_in: 1.hour)
Rails.cache.write(ip_key, ip_count + 1, expires_in: 1.hour)
end
end
Users receive a professionally designed HTML email with clear instructions:
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password for your Feng Shui 2 campaign account. You can do this through the link below.</p>
<p>
<%= link_to 'Reset My Password',
edit_password_url(@resource, reset_password_token: @token, protocol: 'https'),
style: 'background-color: #1976d2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;' %>
</p>
<p>If you didn't request this change, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>
When users click the email link, they're directed to /reset-password/[token] where token validation and password creation happen:
export default function ResetPasswordForm({
token,
onSubmit,
loading,
error
}: ResetPasswordFormProps) {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState<string | null>(null)
const validatePassword = (pwd: string): string | null => {
if (pwd.length < 8) return 'Password must be at least 8 characters long'
if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(pwd)) {
return 'Password must contain at least one letter and one number'
}
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const passwordValidation = validatePassword(password)
if (passwordValidation) {
setPasswordError(passwordValidation)
return
}
if (password !== confirmPassword) {
setPasswordError('Passwords do not match')
return
}
await onSubmit(token, password, confirmPassword)
}
// Material UI form implementation...
}
Our previous password reset implementation had several security gaps that needed addressing:
The old system also created friction for legitimate users:
One of the biggest challenges was implementing security measures without creating a frustrating user experience. For example, we needed to prevent email enumeration attacks while still providing helpful feedback to users.
Solution: We implemented consistent messaging that always indicates success, regardless of whether the email exists:
// API response always returns success
const response = {
message: "If your email address exists in our database, you will receive password reset instructions shortly."
}
// But we log different outcomes for monitoring
if (userExists) {
logger.info(`Password reset sent for ${email}`)
} else {
logger.info(`Password reset attempted for non-existent email: ${email}`)
}
Implementing effective rate limiting required careful consideration of legitimate use cases versus potential abuse:
# Different limits for email vs IP to handle shared networks
EMAIL_RATE_LIMIT = 3.per.hour # Per email address
IP_RATE_LIMIT = 5.per.hour # Per IP address
def rate_limit_password_resets
email_key = "password_reset_rate_limit:email:#{email}"
ip_key = "password_reset_rate_limit:ip:#{request.remote_ip}"
# Check both limits and provide appropriate feedback
if exceeded_limits?
render json: {
error: "Too many password reset attempts. Please wait before trying again.",
retry_after: 3600
}, status: :too_many_requests
end
end
Ensuring reset tokens were secure while maintaining a smooth user experience required implementing robust validation:
# Backend token validation
def update
self.resource = resource_class.reset_password_by_token(resource_params)
if resource.errors.empty?
resource.unlock_access! if unlockable?(resource)
sign_in(resource_name, resource)
render json: {
message: "Password successfully updated",
redirect_url: after_resetting_password_path_for(resource)
}, status: :ok
else
render json: {
errors: resource.errors.full_messages
}, status: :unprocessable_entity
end
end
Maintaining consistent behavior across the Rails API and React frontend required careful coordination:
// Frontend API client matching backend expectations
export const resetPassword = async (
token: string,
password: string,
passwordConfirmation: string
): Promise<AxiosResponse<ResetPasswordResponse>> => {
return put(api.resetUserPassword(), {
user: {
reset_password_token: token,
password: password,
password_confirmation: passwordConfirmation
}
})
}
Users now receive immediate feedback as they type, preventing submission errors:
// Password strength indicator
const getPasswordStrength = (password: string): 'weak' | 'medium' | 'strong' => {
if (password.length < 8) return 'weak'
if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(password)) return 'weak'
if (password.length >= 12 && /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/.test(password)) {
return 'strong'
}
return 'medium'
}
// Visual feedback component
<LinearProgress
variant="determinate"
value={strengthPercentage}
color={strengthColor}
sx={{ height: 8, borderRadius: 4 }}
/>
The new interface adapts seamlessly across all device sizes using Material UI's responsive system:
<Container maxWidth="sm">
<Paper
elevation={3}
sx={{
padding: { xs: 3, sm: 4 },
marginTop: { xs: 2, sm: 4 },
marginBottom: { xs: 2, sm: 4 }
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Reset Your Password
</Typography>
{/* Form content... */}
</Paper>
</Container>
Clear visual feedback keeps users informed throughout the process:
<LoadingButton
type="submit"
fullWidth
variant="contained"
loading={loading}
disabled={!email || !!emailError}
sx={{ mt: 3, mb: 2, height: 48 }}
>
{loading ? 'Sending Reset Link...' : 'Send Reset Link'}
</LoadingButton>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mt: 2 }}>
Check your email for reset instructions
</Alert>
)}
We're planning to integrate MFA options for users who want additional security:
// Planned MFA integration
interface MFAOptions {
totpEnabled: boolean
backupCodesGenerated: boolean
lastMFAVerification: Date | null
}
// Enhanced password reset with MFA consideration
const resetPasswordWithMFA = async (token: string, password: string, mfaCode?: string) => {
// Implementation for MFA-protected accounts
}
Exploring magic link authentication as an alternative to traditional passwords:
# Future: Magic link generation
class MagicLinkService
def generate_login_link(email)
# Secure token generation for passwordless login
end
end
Planning to add comprehensive security event logging:
# Future: Security event tracking
class SecurityEventLogger
def log_password_reset_attempt(email:, ip:, success:, reason: nil)
SecurityEvent.create!(
event_type: 'password_reset_attempt',
email: email,
ip_address: ip,
success: success,
additional_info: { reason: reason }
)
end
end
Building a secure, user-friendly password reset system required balancing multiple competing priorities: security, user experience, performance, and maintainability. The result is a robust authentication feature that protects user accounts while providing a smooth recovery experience.
Key takeaways from this implementation:
The modernized password reset system now serves as a foundation for future authentication enhancements, including MFA and passwordless login options. It demonstrates how modern web development practices can create secure, accessible, and delightful user experiences.
This feature was implemented as part of the ongoing evolution of our Feng Shui 2 RPG campaign management platform, built with Rails 8.0, Next.js 15, and Material UI v7. The complete implementation includes comprehensive testing, documentation, and follows OWASP security guidelines.