Email Verification Guide
Guren provides a secure email verification system with token generation, verification, and expiration. The token is signed rather than stored: only an opaque token id reaches the store.
Email Verification Guide
Guren provides a secure email verification system with token generation, verification, and expiration. The token is signed rather than stored: only an opaque token id reaches the store.
Core Concepts
- EmailVerificationTokenStore – Interface for storing verification tokens.
- Signed Tokens – The token is signed with a key derived from
APP_KEY, and carries its own expiry. - No Plain Storage – Only an opaque token id is stored, never the token itself.
- One Authority on Expiry – Verification reads the expiry signed into the token; the store is asked only whether the token id still exists.
- Single Use – Tokens are deleted after successful verification.
- Expiration – Tokens expire after a configurable time (default: 24 hours).
Basic Usage
Creating a Verification Token
import { createEmailVerificationToken, MemoryEmailVerificationStore } from '@guren/core'
const store = new MemoryEmailVerificationStore() // Use database in production
// Create a verification token
const { token, expiresAt } = await createEmailVerificationToken(
'user@example.com',
store
)
// Send verification email
await sendVerificationEmail(email, token)
Verifying a Token (Read-Only)
import { verifyEmailToken } from '@guren/core'
// Check if token is valid without consuming it
const email = await verifyEmailToken(token, store)
if (!email) {
return ctx.json({ error: 'Invalid or expired token' }, 400)
}
// Token is valid, but not yet consumed
return ctx.json({ email, valid: true })
Completing Verification
import { completeEmailVerification } from '@guren/core'
const user = await completeEmailVerification(
token,
store,
async (email) => {
// Mark user as verified
await User.update(
{ email },
{ emailVerifiedAt: new Date() }
)
return User.findByEmail(email)
}
)
if (!user) {
return ctx.json({ error: 'Invalid or expired token' }, 400)
}
return ctx.redirect('/dashboard')
Full Implementation Example
Routes
import { Router } from '@guren/core'
import { VerificationController } from '@/app/Controllers/VerificationController'
export function registerWebRoutes(router: Router): void {
router.middleware('auth').group((auth) => {
auth.get('/email/verify', [VerificationController, 'notice'])
auth.post('/email/resend', [VerificationController, 'resend'])
})
router.get('/email/verify/:token', [VerificationController, 'verify'])
}
Controller
import { Controller } from '@guren/core'
import {
createEmailVerificationToken,
completeEmailVerification,
buildVerificationUrl,
isEmailVerified,
} from '@guren/core'
import { User } from '@/app/Models/User'
import { pages } from '@/.guren/pages.gen'
export class VerificationController extends Controller {
private store = new DatabaseEmailVerificationStore()
async notice() {
const user = await this.auth.user()
if (isEmailVerified(user)) {
return this.redirect('/dashboard')
}
return this.inertia(pages.auth.VerifyEmail, {
email: user.email,
})
}
async resend() {
const user = await this.auth.user()
if (isEmailVerified(user)) {
return this.json({ message: 'Already verified' })
}
const { token } = await createEmailVerificationToken(
user.email,
this.store,
{ expiresIn: 24 * 60 * 60 * 1000 } // 24 hours
)
const verifyUrl = buildVerificationUrl(
`${process.env.APP_URL}/email/verify`,
token
)
await this.sendVerificationEmail(user, verifyUrl)
return this.json({ message: 'Verification email sent' })
}
async verify() {
const token = this.request.param('token')
const user = await completeEmailVerification(
token,
this.store,
async (email) => {
await User.where('email', email).update({
emailVerifiedAt: new Date(),
})
return User.where('email', email).first()
}
)
if (!user) {
return this.inertia(pages.auth.VerifyEmail, {
error: 'Invalid or expired verification link',
})
}
return this.redirect('/dashboard?verified=1')
}
private async sendVerificationEmail(user: User, verifyUrl: string) {
await mail.send({
to: user.email,
subject: 'Verify Your Email Address',
html: `
<h1>Verify Your Email</h1>
<p>Click the button below to verify your email address:</p>
<a href="${verifyUrl}" style="...">Verify Email</a>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, no action is required.</p>
`,
})
}
}
Helper Functions
Check Verification Status
import { isEmailVerified } from '@guren/core'
// Check if user is verified
if (isEmailVerified(user)) {
// User's email is verified
}
// Works with nullable users
if (!isEmailVerified(null)) {
// Returns false for null
}
Require Verified Email Middleware
import { Router, requireVerifiedEmail } from '@guren/core'
const router = new Router()
// Redirect unverified users
router.get('/dashboard', [DashboardController, 'index']).middleware(
requireVerifiedEmail({ redirectTo: '/email/verify' })
)
// Custom user getter
router.get('/profile', [ProfileController, 'show']).middleware(
requireVerifiedEmail({
redirectTo: '/verify-email',
getUser: async (ctx) => {
return ctx.get('user')
},
})
)
URL Helpers
Building Verification URLs
import { buildVerificationUrl } from '@guren/core'
// Basic URL with token
const url = buildVerificationUrl('https://example.com/verify', token)
// Result: https://example.com/verify?token=abc123...
// With email parameter
const urlWithEmail = buildVerificationUrl(
'https://example.com/verify',
token,
'user@example.com'
)
// Result: https://example.com/verify?token=abc123...&email=user%40example.com
Parsing Verification URLs
import { parseVerificationUrl } from '@guren/core'
const { token, email } = parseVerificationUrl(
'https://example.com/verify?token=abc123&email=user%40example.com'
)
console.log(token) // 'abc123'
console.log(email) // 'user@example.com'
Database Storage
Implementing EmailVerificationTokenStore
import type { EmailVerificationTokenStore, EmailVerificationToken } from '@guren/core'
import { emailVerifications } from '@/db/schema'
import { eq } from 'drizzle-orm'
export class DatabaseEmailVerificationStore implements EmailVerificationTokenStore {
async store(token: EmailVerificationToken): Promise<void> {
await db.insert(emailVerifications).values({
tokenId: token.tokenId,
email: token.email,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
})
}
async findByTokenId(tokenId: string): Promise<EmailVerificationToken | null> {
const result = await db.select()
.from(emailVerifications)
.where(eq(emailVerifications.tokenId, tokenId))
.limit(1)
if (!result[0]) return null
return {
email: result[0].email,
tokenId: result[0].tokenId,
expiresAt: result[0].expiresAt,
createdAt: result[0].createdAt,
}
}
async delete(tokenId: string): Promise<void> {
await db.delete(emailVerifications)
.where(eq(emailVerifications.tokenId, tokenId))
}
async deleteForEmail(email: string): Promise<void> {
await db.delete(emailVerifications)
.where(eq(emailVerifications.email, email.toLowerCase()))
}
}
Database Schema
// db/schema.ts
import { pgTable, text, timestamp } from '@guren/orm/drizzle/pg'
export const emailVerifications = pgTable('email_verifications', {
tokenId: text('token_id').primaryKey(),
email: text('email').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// User table should include emailVerifiedAt
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
// ... other fields
})
The physical column name is yours to choose; only the store's method signatures are fixed. An earlier version of this guide called it hashed_token, 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 EmailVerificationConfig {
/** Token expiration time in milliseconds (default: 24 hours) */
expiresIn?: number
/** Token byte length before hex encoding (default: 32) */
tokenLength?: number
}
// Example with custom configuration
const { token } = await createEmailVerificationToken(email, store, {
expiresIn: 48 * 60 * 60 * 1000, // 48 hours
tokenLength: 64,
})
The configuration applies when a token is issued. createEmailVerificationToken signs the expiry into the token itself, so verifyEmailToken and completeEmailVerification 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 {
createEmailVerificationToken,
verifyEmailToken,
completeEmailVerification,
isEmailVerified,
MemoryEmailVerificationStore,
} from '@guren/core'
describe('Email Verification', () => {
let store: MemoryEmailVerificationStore
beforeEach(() => {
store = new MemoryEmailVerificationStore()
})
test('creates and verifies token', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store)
const email = await verifyEmailToken(token, store)
expect(email).toBe('user@example.com')
})
test('normalizes email to lowercase', async () => {
const { token } = await createEmailVerificationToken('User@Example.COM', store)
const email = await verifyEmailToken(token, store)
expect(email).toBe('user@example.com')
})
test('rejects expired tokens', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store, {
expiresIn: -1000, // Already expired
})
const email = await verifyEmailToken(token, store)
expect(email).toBeNull()
})
test('completes verification and consumes token', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store)
const result = await completeEmailVerification(
token,
store,
async (email) => ({ email, verified: true })
)
expect(result).toEqual({ email: 'user@example.com', verified: true })
// Token should be consumed
const secondAttempt = await verifyEmailToken(token, store)
expect(secondAttempt).toBeNull()
})
test('isEmailVerified helper works correctly', () => {
expect(isEmailVerified({ emailVerifiedAt: new Date() })).toBe(true)
expect(isEmailVerified({ emailVerifiedAt: null })).toBe(false)
expect(isEmailVerified(null)).toBe(false)
})
})
Registration Flow Example
// UserController.ts
const RegisterSchema = z.object({
name: z.string().min(2),
email: z.email(),
password: z.string().min(8),
})
async register() {
const { name, email, password } = await this.validateBody(RegisterSchema)
// Create user
const user = await User.create({
name,
email,
password: await Bun.password.hash(password),
emailVerifiedAt: null,
})
// Create verification token
const { token } = await createEmailVerificationToken(email, this.store)
// Send verification email
const verifyUrl = buildVerificationUrl(
`${process.env.APP_URL}/email/verify`,
token
)
await this.sendVerificationEmail(user, verifyUrl)
// Log the user in
await this.auth.login(user)
// Redirect to verification notice
return this.redirect('/email/verify')
}
Best Practices
Use longer expiration times: Email verification tokens can safely expire in 24-72 hours.
Normalize emails: Store emails in lowercase to prevent case-sensitivity issues.
Allow resending: Let users request a new verification email if needed.
Clear old tokens: Creating a new token automatically deletes old ones for the same email.
Protect routes: Use
requireVerifiedEmailmiddleware for routes that need verified users.Handle already verified: Check
isEmailVerified()before sending new tokens.Use database storage:
MemoryEmailVerificationStoreis only for testing.Send during registration: Automatically send verification email when users register.