Password Reset Guide
Guren provides a secure password reset system with token generation, verification, and expiration. The token is signed rather than stored: only an opaque token id reaches the store.
Password Reset Guide
Guren provides a secure password reset system with token generation, verification, and expiration. The token is signed rather than stored: only an opaque token id reaches the store.
Core Concepts
- PasswordResetTokenStore – Interface for storing password reset tokens.
- Signed Tokens – The token is signed with a key derived from
APP_KEY, and carries its own expiry. Only an opaque token id is stored. - One Authority on Expiry – Verification reads the expiry signed into the token; the store is asked only whether the token id still exists.
- Automatic Cleanup – Creating a new token invalidates any existing tokens for the same email.
- Expiration – Tokens expire after a configurable time (default: 1 hour).
Basic Usage
Creating a Password Reset Token
import { createPasswordResetToken, MemoryPasswordResetStore } from '@guren/core'
const store = new MemoryPasswordResetStore() // Use database in production
// Create a password reset token
const { token, expiresAt } = await createPasswordResetToken(
'user@example.com',
store
)
// Send the token to the user via email
await sendPasswordResetEmail(email, token)
Verifying a Token
import { verifyPasswordResetToken } from '@guren/core'
// Verify the token from the reset URL
const email = await verifyPasswordResetToken(token, store)
if (!email) {
return ctx.json({ error: 'Invalid or expired token' }, 400)
}
// Token is valid, show password reset form
return ctx.json({ email })
Completing the Password Reset
import { completePasswordReset } from '@guren/core'
const user = await completePasswordReset(
token,
newPassword,
store,
userProvider,
async (user, password) => {
// Hash and update the password
user.password = await hashPassword(password)
await user.save()
}
)
if (!user) {
return ctx.json({ error: 'Invalid token or user not found' }, 400)
}
return ctx.json({ message: 'Password updated successfully' })
Full Implementation Example
Routes
import { Router } from '@guren/core'
import { PasswordResetController } from '@/app/Controllers/PasswordResetController'
export function registerWebRoutes(router: Router): void {
router.post('/forgot-password', [PasswordResetController, 'sendResetLink'])
router.get('/reset-password', [PasswordResetController, 'showResetForm'])
router.post('/reset-password', [PasswordResetController, 'resetPassword'])
}
Controller
import { Controller } from '@guren/core'
import { z } from 'zod'
import {
createPasswordResetToken,
verifyPasswordResetToken,
completePasswordReset,
buildPasswordResetUrl,
} from '@guren/core'
import { User } from '@/app/Models/User'
import { pages } from '@/.guren/pages.gen'
const ForgotPasswordSchema = z.object({
email: z.email(),
})
const ResetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8),
})
export class PasswordResetController extends Controller {
private store = new DatabasePasswordResetStore()
private userProvider = new EloquentUserProvider(User)
async sendResetLink() {
const { email } = await this.validateBody(ForgotPasswordSchema)
// Always return success to prevent email enumeration
const user = await User.where('email', email).first()
if (user) {
const { token } = await createPasswordResetToken(email, this.store, {
expiresIn: 60 * 60 * 1000, // 1 hour
})
const resetUrl = buildPasswordResetUrl(
`${process.env.APP_URL}/reset-password`,
token,
email
)
await this.sendResetEmail(user, resetUrl)
}
return this.json({
message: 'If your email is registered, you will receive a reset link',
})
}
async showResetForm() {
const token = this.request.query('token')
const email = await verifyPasswordResetToken(token, this.store)
if (!email) {
return this.inertia(pages.auth.ResetPassword, {
error: 'Invalid or expired reset link',
})
}
return this.inertia(pages.auth.ResetPassword, { token, email })
}
async resetPassword() {
const { token, password } = await this.validateBody(ResetPasswordSchema)
const user = await completePasswordReset(
token,
password,
this.store,
this.userProvider,
async (user, newPassword) => {
user.password = await Bun.password.hash(newPassword)
await user.save()
}
)
if (!user) {
return this.json({ error: 'Invalid or expired token' }, 400)
}
return this.json({ message: 'Password has been reset' })
}
private async sendResetEmail(user: User, resetUrl: string) {
await mail.send({
to: user.email,
subject: 'Reset Your Password',
html: `
<h1>Password Reset Request</h1>
<p>Click the link below to reset your password:</p>
<a href="${resetUrl}">Reset Password</a>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
`,
})
}
}
URL Helpers
Building Reset URLs
import { buildPasswordResetUrl } from '@guren/core'
// Basic URL with token
const url = buildPasswordResetUrl('https://example.com/reset', token)
// Result: https://example.com/reset?token=abc123...
// With email parameter
const urlWithEmail = buildPasswordResetUrl(
'https://example.com/reset',
token,
'user@example.com'
)
// Result: https://example.com/reset?token=abc123...&email=user%40example.com
Parsing Reset URLs
import { parsePasswordResetUrl } from '@guren/core'
const { token, email } = parsePasswordResetUrl(
'https://example.com/reset?token=abc123&email=user%40example.com'
)
console.log(token) // 'abc123'
console.log(email) // 'user@example.com'
Database Storage
Implementing PasswordResetTokenStore
import type { PasswordResetTokenStore } from '@guren/core'
import { passwordResets } from '@/db/schema'
import { eq, lt } from 'drizzle-orm'
export class DatabasePasswordResetStore implements PasswordResetTokenStore {
async store(tokenId: string, email: string, expiresAt: Date): Promise<void> {
await db.insert(passwordResets).values({
tokenId,
email,
expiresAt,
createdAt: new Date(),
})
}
async find(tokenId: string): Promise<{ email: string; expiresAt: Date } | null> {
const result = await db.select()
.from(passwordResets)
.where(eq(passwordResets.tokenId, tokenId))
.limit(1)
if (!result[0]) return null
// Housekeeping only. Expiry is enforced from the claim signed into the
// token, so returning a stale row here would not extend a reset link.
if (result[0].expiresAt < new Date()) {
await this.delete(tokenId)
return null
}
return {
email: result[0].email,
expiresAt: result[0].expiresAt,
}
}
async delete(tokenId: string): Promise<void> {
await db.delete(passwordResets)
.where(eq(passwordResets.tokenId, tokenId))
}
async deleteForEmail(email: string): Promise<void> {
await db.delete(passwordResets)
.where(eq(passwordResets.email, email))
}
// Optional: Clean up expired tokens
async cleanupExpired(): Promise<void> {
await db.delete(passwordResets)
.where(lt(passwordResets.expiresAt, new Date()))
}
}
Database Schema
// db/schema.ts
import { pgTable, text, timestamp } from '@guren/orm/drizzle/pg'
export const passwordResets = pgTable('password_resets', {
tokenId: text('token_id').primaryKey(),
email: text('email').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
The physical column name is yours to choose; only the store's method signatures are fixed. An earlier version of this guide called it token_hash, from when the store held a hash of the token. If you already have that column, keep it and map it to tokenId rather than migrating.
Configuration
Token Options
interface PasswordResetConfig {
/** Token expiration time in milliseconds (default: 1 hour) */
expiresIn?: number
/** Token byte length before encoding (default: 32) */
tokenLength?: number
}
// Example with custom configuration
const { token } = await createPasswordResetToken(email, store, {
expiresIn: 30 * 60 * 1000, // 30 minutes
tokenLength: 64,
})
The configuration applies when a token is issued. createPasswordResetToken signs the expiry into the token itself, so verifyPasswordResetToken and completePasswordReset take no configuration: expiry is read from the token's signed claim, and the store is only asked whether the token id still exists. Changing expiresIn affects tokens issued from then on, not links already sent, and the signing key comes from APP_KEY.
Testing
import { describe, test, expect, beforeEach } from 'bun:test'
import {
createPasswordResetToken,
verifyPasswordResetToken,
completePasswordReset,
MemoryPasswordResetStore,
} from '@guren/core'
describe('Password Reset', () => {
let store: MemoryPasswordResetStore
beforeEach(() => {
store = new MemoryPasswordResetStore()
})
test('creates and verifies token', async () => {
const { token } = await createPasswordResetToken('user@example.com', store)
const email = await verifyPasswordResetToken(token, store)
expect(email).toBe('user@example.com')
})
test('rejects expired tokens', async () => {
const { token } = await createPasswordResetToken('user@example.com', store, {
expiresIn: -1000, // Already expired
})
const email = await verifyPasswordResetToken(token, store)
expect(email).toBeNull()
})
test('invalidates previous tokens on new request', async () => {
const { token: oldToken } = await createPasswordResetToken('user@example.com', store)
const { token: newToken } = await createPasswordResetToken('user@example.com', store)
expect(await verifyPasswordResetToken(oldToken, store)).toBeNull()
expect(await verifyPasswordResetToken(newToken, store)).toBe('user@example.com')
})
test('token can only be used once', async () => {
const { token } = await createPasswordResetToken('user@example.com', store)
// First use
const user = await completePasswordReset(
token,
'new-password',
store,
userProvider,
async (u, p) => { u.password = p }
)
expect(user).not.toBeNull()
// Second use should fail
const email = await verifyPasswordResetToken(token, store)
expect(email).toBeNull()
})
})
Best Practices
Always return success on forgot password: Prevent email enumeration attacks by always showing a success message.
Use short expiration times: Password reset tokens should expire quickly (15-60 minutes).
Invalidate on password change: When a user changes their password, delete all their reset tokens.
Rate limit requests: Prevent abuse by rate limiting the forgot password endpoint.
Use HTTPS: Reset links contain sensitive tokens and must be sent over HTTPS.
Don't include password in email: Only send the reset link, never the new password.
Log reset attempts: Log password reset requests for security auditing.
Notify user of password changes: Send an email when the password is successfully changed.