Trust Tokens Guide
Complete guide to implementing secure, single-use tokens with PASETO in Flowfull.
What are Trust Tokens?
Trust Tokens are cryptographically secure tokens used for:
- 📧 Email verification - Verify user emails securely
- 🔑 Password reset - Secure password recovery links
- 👥 Invitations - Invite users to organizations/teams
- 🎫 API access - Temporary API access tokens
- ✅ One-time actions - Secure single-use tokens
Why PASETO Instead of JWT?
| Feature | JWT | PASETO |
|---|---|---|
| Security | ⚠️ Algorithm confusion attacks | ✅ No algorithm choice |
| Encryption | ❌ Not built-in | ✅ Built-in encryption |
| Simplicity | ⚠️ Complex configuration | ✅ Simple API |
| Modern Crypto | ⚠️ Old algorithms | ✅ Ed25519, XChaCha20 |
PASETO = Platform-Agnostic SEcurity TOkens
How Trust Tokens Work
┌─────────────────────────────────────────────────────────┐
│ TRUST TOKEN FLOW │
├─────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ Generate Token │
│ └─ Server creates token with Ed25519 private key │
│ │
│ 2️⃣ Send to User │
│ └─ Email, SMS, or API response │
│ │
│ 3️⃣ User Clicks Link │
│ └─ Token sent back to server │
│ │
│ 4️⃣ Validate Token (6 Security Layers) │
│ ├─ Layer 1: PASETO signature (Ed25519) │
│ ├─ Layer 2: Expiration check │
│ ├─ Layer 3: Redis status check │
│ ├─ Layer 4: Database status check │
│ ├─ Layer 5: User ownership check │
│ └─ Layer 6: Resource validation │
│ │
│ 5️⃣ Consume Token │
│ └─ Mark as used, perform action │
│ │
└─────────────────────────────────────────────────────────┘Installation
npm install pasetoyarn add pasetobun add pasetoSetup
Step 1: Database Schema
Create the trust_tokens table for tracking tokens:
// migrations/003_create_trust_tokens.ts
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('trust_tokens')
.addColumn('id', 'varchar(255)', (col) => col.primaryKey())
.addColumn('user_id', 'varchar(255)', (col) => col.notNull())
.addColumn('type', 'varchar(50)', (col) => col.notNull()) // email_verification, password_reset, invitation
.addColumn('status', 'varchar(50)', (col) => col.notNull()) // pending, used, expired
.addColumn('expires_at', 'timestamp', (col) => col.notNull())
.addColumn('used_at', 'timestamp')
.addColumn('created_at', 'timestamp', (col) => col.notNull())
.execute();
// Create indexes for performance
await db.schema
.createIndex('trust_tokens_user_id_idx')
.on('trust_tokens')
.column('user_id')
.execute();
await db.schema
.createIndex('trust_tokens_type_status_idx')
.on('trust_tokens')
.columns(['type', 'status'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('trust_tokens').execute();
}Run the migration:
bun run migrateStep 2: Generate Key Pair
Create a script to generate Ed25519 keys:
// scripts/generate-paseto-keys.ts
import { V4 } from 'paseto';
const keyPair = await V4.generateKey('public');
console.log('✅ PASETO Keys Generated!\n');
console.log('Add to your .env file:\n');
console.log(`PASETO_PRIVATE_KEY=${keyPair.privateKey}`);
console.log(`PASETO_PUBLIC_KEY=${keyPair.publicKey}`);Run it:
bun run scripts/generate-paseto-keys.tsKeep Keys Secure
- Never commit these keys to version control
- Store securely in environment variables
- Rotate regularly in production
- Use different keys for dev/staging/production
Configure Environment
Add these variables to your .env file:
# ============================================
# PASETO Configuration (Trust Tokens)
# ============================================
# PASETO Keys (Generate with: bun run scripts/generate-paseto-keys.ts)
PASETO_PRIVATE_KEY=k4.secret.your-private-key-here
PASETO_PUBLIC_KEY=k4.public.your-public-key-here
# Token Expiration Times (in seconds)
EMAIL_VERIFICATION_TTL=86400 # 24 hours (1 day)
PASSWORD_RESET_TTL=3600 # 1 hour
INVITATION_TTL=604800 # 7 days
API_TOKEN_TTL=2592000 # 30 days
# ============================================
# Email Configuration (for sending tokens)
# ============================================
# Email Provider (zeptomail, sendgrid, ses, etc.)
EMAIL_PROVIDER=zeptomail
# ZeptoMail Configuration
ZEPTOMAIL_API_KEY=your-zeptomail-api-key
ZEPTOMAIL_FROM_EMAIL=noreply@yourdomain.com
ZEPTOMAIL_FROM_NAME=Your App Name
# Email Templates
EMAIL_VERIFICATION_TEMPLATE_ID=template_123
PASSWORD_RESET_TEMPLATE_ID=template_456
INVITATION_TEMPLATE_ID=template_789
# ============================================
# Application URLs (for token links)
# ============================================
# Frontend URL (where users click token links)
FRONTEND_URL=https://yourdomain.com
# or for development:
# FRONTEND_URL=http://localhost:3000
# Backend URL (for API calls)
BACKEND_URL=https://api.yourdomain.com
# or for development:
# BACKEND_URL=http://localhost:3000
# ============================================
# Redis Configuration (for token tracking)
# ============================================
REDIS_URL=redis://localhost:6379
# or for production:
# REDIS_URL=redis://:password@your-redis-host:6379
# ============================================
# Database Configuration
# ============================================
DATABASE_TYPE=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/mydbSecurity Best Practices
- Never commit
.envfiles to version control - Rotate keys regularly in production
- Use different keys for development and production
- Store keys securely using environment variable managers (AWS Secrets Manager, Vault, etc.)
Environment Validation
Create src/lib/config/trust-tokens.ts to validate configuration:
import { z } from 'zod';
const trustTokensConfigSchema = z.object({
// PASETO Keys
PASETO_PRIVATE_KEY: z.string().startsWith('k4.secret.'),
PASETO_PUBLIC_KEY: z.string().startsWith('k4.public.'),
// Token TTLs
EMAIL_VERIFICATION_TTL: z.coerce.number().min(60).default(86400),
PASSWORD_RESET_TTL: z.coerce.number().min(60).default(3600),
INVITATION_TTL: z.coerce.number().min(60).default(604800),
API_TOKEN_TTL: z.coerce.number().min(60).default(2592000),
// Email Configuration
EMAIL_PROVIDER: z.enum(['zeptomail', 'sendgrid', 'ses', 'smtp']),
ZEPTOMAIL_API_KEY: z.string().optional(),
ZEPTOMAIL_FROM_EMAIL: z.string().email().optional(),
ZEPTOMAIL_FROM_NAME: z.string().optional(),
// URLs
FRONTEND_URL: z.string().url(),
BACKEND_URL: z.string().url(),
// Redis
REDIS_URL: z.string().url(),
});
export const trustTokensConfig = trustTokensConfigSchema.parse(process.env);
export type TrustTokensConfig = z.infer<typeof trustTokensConfigSchema>;Fail-Fast Validation
This configuration will fail immediately on startup if any required environment variable is missing or invalid. This prevents runtime errors in production!
Real-World Examples
Example 1: Email Verification
Complete implementation with environment configuration:
// src/lib/trust-tokens/email-verification.ts
import { V4 } from 'paseto';
import { redis } from '../cache/redis';
import { db } from '../database';
import { trustTokensConfig } from '../config/trust-tokens';
// Generate verification token
export async function generateEmailVerificationToken(
userId: string,
email: string
): Promise<string> {
const payload = {
userId,
email,
type: 'email_verification',
exp: new Date(
Date.now() + trustTokensConfig.EMAIL_VERIFICATION_TTL * 1000
).toISOString()
};
// Sign with PASETO private key
const token = await V4.sign(
payload,
trustTokensConfig.PASETO_PRIVATE_KEY
);
// Store in Redis for tracking (Layer 3 validation)
await redis.set(
`email_verify:${userId}`,
JSON.stringify({
status: 'pending',
email,
createdAt: new Date().toISOString()
}),
'EX',
trustTokensConfig.EMAIL_VERIFICATION_TTL
);
// Store in database for audit trail (Layer 4 validation)
await db.insertInto('trust_tokens')
.values({
id: `token_${Date.now()}`,
user_id: userId,
type: 'email_verification',
status: 'pending',
expires_at: new Date(
Date.now() + trustTokensConfig.EMAIL_VERIFICATION_TTL * 1000
).toISOString(),
created_at: new Date().toISOString()
})
.execute();
return token;
}
// Send verification email
export async function sendVerificationEmail(
userId: string,
email: string
): Promise<void> {
const token = await generateEmailVerificationToken(userId, email);
const verificationUrl = `${trustTokensConfig.FRONTEND_URL}/verify-email?token=${token}`;
// Send email using configured provider
await sendEmail({
to: email,
subject: 'Verify your email',
template: trustTokensConfig.EMAIL_VERIFICATION_TEMPLATE_ID,
variables: {
verificationUrl,
expiresIn: '24 hours'
}
});
console.log(`✅ Verification email sent to ${email}`);
}
// Verify token endpoint
// src/routes/auth/verify-email.ts
import { Hono } from 'hono';
import { V4 } from 'paseto';
import { redis } from '../../lib/cache/redis';
import { db } from '../../lib/database';
import { trustTokensConfig } from '../../lib/config/trust-tokens';
const app = new Hono();
app.post('/verify-email', async (c) => {
const { token } = await c.req.json();
try {
// ✅ Layer 1: Verify PASETO signature (Ed25519)
const payload = await V4.verify(
token,
trustTokensConfig.PASETO_PUBLIC_KEY
);
// ✅ Layer 2: Check expiration
if (new Date(payload.exp) < new Date()) {
return c.json({ error: 'Token expired' }, 400);
}
// ✅ Layer 3: Check Redis status
const redisStatus = await redis.get(`email_verify:${payload.userId}`);
if (!redisStatus) {
return c.json({ error: 'Token already used or invalid' }, 400);
}
// ✅ Layer 4: Check database status
const dbToken = await db
.selectFrom('trust_tokens')
.where('user_id', '=', payload.userId)
.where('type', '=', 'email_verification')
.where('status', '=', 'pending')
.selectAll()
.executeTakeFirst();
if (!dbToken) {
return c.json({ error: 'Token not found in database' }, 400);
}
// ✅ Layer 5: Verify user ownership
const user = await db
.selectFrom('users')
.where('id', '=', payload.userId)
.where('email', '=', payload.email)
.selectAll()
.executeTakeFirst();
if (!user) {
return c.json({ error: 'User not found or email mismatch' }, 400);
}
// ✅ Layer 6: Perform action - Update user
await db.updateTable('users')
.set({
email_verified: true,
email_verified_at: new Date().toISOString()
})
.where('id', '=', payload.userId)
.execute();
// Mark token as used in database
await db.updateTable('trust_tokens')
.set({
status: 'used',
used_at: new Date().toISOString()
})
.where('user_id', '=', payload.userId)
.where('type', '=', 'email_verification')
.execute();
// Invalidate token in Redis
await redis.del(`email_verify:${payload.userId}`);
console.log(`✅ Email verified for user ${payload.userId}`);
return c.json({
success: true,
message: 'Email verified successfully'
});
} catch (error) {
console.error('❌ Email verification failed:', error);
return c.json({ error: 'Invalid token' }, 400);
}
});
export default app;6 Security Layers
This implementation uses all 6 security layers:
- PASETO signature - Cryptographic verification
- Expiration check - Time-based validation
- Redis status - Fast cache lookup
- Database status - Persistent validation
- User ownership - Verify user exists and email matches
- Resource validation - Ensure token hasn't been used
Example 2: Password Reset
Complete implementation with security best practices:
// src/lib/trust-tokens/password-reset.ts
import { V4 } from 'paseto';
import { redis } from '../cache/redis';
import { db } from '../database';
import { trustTokensConfig } from '../config/trust-tokens';
import bcrypt from 'bcrypt';
// Generate reset token
export async function generatePasswordResetToken(
userId: string,
email: string
): Promise<string> {
const payload = {
userId,
email,
type: 'password_reset',
exp: new Date(
Date.now() + trustTokensConfig.PASSWORD_RESET_TTL * 1000
).toISOString()
};
const token = await V4.sign(
payload,
trustTokensConfig.PASETO_PRIVATE_KEY
);
// Store in Redis (Layer 3)
await redis.set(
`password_reset:${userId}`,
JSON.stringify({
status: 'pending',
email,
createdAt: new Date().toISOString()
}),
'EX',
trustTokensConfig.PASSWORD_RESET_TTL
);
// Store in database (Layer 4)
await db.insertInto('trust_tokens')
.values({
id: `token_${Date.now()}`,
user_id: userId,
type: 'password_reset',
status: 'pending',
expires_at: new Date(
Date.now() + trustTokensConfig.PASSWORD_RESET_TTL * 1000
).toISOString(),
created_at: new Date().toISOString()
})
.execute();
return token;
}
// Send reset email
async function sendPasswordResetEmail(email: string) {
const user = await db.selectFrom('users')
.where('email', '=', email)
.select(['id', 'email'])
.executeTakeFirst();
if (!user) {
// Don't reveal if email exists
return;
}
const token = await generatePasswordResetToken(user.id, user.email);
const resetUrl = `https://your-app.com/reset-password?token=${token}`;
await sendEmail({
to: email,
subject: 'Reset your password',
html: `
<h1>Password Reset</h1>
<p>Click the link below to reset your password:</p>
<a href="${resetUrl}">Reset Password</a>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
`
});
}
// Reset password
app.post('/api/reset-password', async (c) => {
const { token, newPassword } = await c.req.json();
try {
const payload = await V4.verify(token, process.env.PASETO_PUBLIC_KEY);
// Validate
if (new Date(payload.exp) < new Date()) {
return c.json({ error: 'Token expired' }, 400);
}
const status = await redis.get(`password_reset:${payload.userId}`);
if (!status) {
return c.json({ error: 'Token already used or invalid' }, 400);
}
// Hash password
const hashedPassword = await Bun.password.hash(newPassword);
// Update password
await db.updateTable('users')
.set({ password: hashedPassword })
.where('id', '=', payload.userId)
.execute();
// Invalidate token
await redis.del(`password_reset:${payload.userId}`);
return c.json({ success: true });
} catch (error) {
return c.json({ error: 'Invalid token' }, 400);
}
});Example 3: Organization Invitations
// Generate invitation token
async function generateInvitationToken(
organizationId: string,
email: string,
role: string,
invitedBy: string
) {
const payload = {
organizationId,
email,
role,
invitedBy,
type: 'org_invitation',
exp: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days
};
const token = await V4.sign(payload, process.env.PASETO_PRIVATE_KEY);
// Store in database
await db.insertInto('invitations')
.values({
id: nanoid(),
organization_id: organizationId,
email,
role,
invited_by: invitedBy,
token,
status: 'pending',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
})
.execute();
return token;
}
// Send invitation
async function sendOrganizationInvitation(
organizationId: string,
email: string,
role: string,
invitedBy: string
) {
const token = await generateInvitationToken(organizationId, email, role, invitedBy);
const inviteUrl = `https://your-app.com/accept-invite?token=${token}`;
const org = await db.selectFrom('organizations')
.where('id', '=', organizationId)
.select('name')
.executeTakeFirst();
await sendEmail({
to: email,
subject: `You've been invited to ${org.name}`,
html: `
<h1>Organization Invitation</h1>
<p>You've been invited to join ${org.name} as a ${role}.</p>
<a href="${inviteUrl}">Accept Invitation</a>
<p>This invitation expires in 7 days.</p>
`
});
}
// Accept invitation
app.post('/api/accept-invitation', requireAuth(), async (c) => {
const { token } = await c.req.json();
const userId = c.get('user_id');
try {
const payload = await V4.verify(token, process.env.PASETO_PUBLIC_KEY);
// Validate
if (new Date(payload.exp) < new Date()) {
return c.json({ error: 'Invitation expired' }, 400);
}
// Check database
const invitation = await db.selectFrom('invitations')
.where('token', '=', token)
.where('status', '=', 'pending')
.selectAll()
.executeTakeFirst();
if (!invitation) {
return c.json({ error: 'Invalid invitation' }, 400);
}
// Add user to organization
await db.insertInto('org_members')
.values({
id: nanoid(),
organization_id: payload.organizationId,
user_id: userId,
role: payload.role
})
.execute();
// Mark invitation as accepted
await db.updateTable('invitations')
.set({ status: 'accepted', accepted_at: new Date() })
.where('id', '=', invitation.id)
.execute();
return c.json({ success: true });
} catch (error) {
return c.json({ error: 'Invalid token' }, 400);
}
});6 Security Layers
Trust Tokens are validated through 6 layers:
- PASETO Signature - Ed25519 cryptographic verification
- Expiration Check - Token must not be expired
- Redis Status - Token must not be invalidated
- Database Status - Token must exist in database
- User Ownership - User must own the resource
- Resource Validation - Resource must exist and be valid
Best Practices
✅ Do
- Use short expiration times
- Invalidate tokens after use
- Store tokens in database
- Use Redis for quick invalidation
- Validate token type
- Send tokens via secure channels (HTTPS)
❌ Don't
- Reuse tokens
- Use long expiration times
- Skip invalidation
- Use for sessions (use session tokens instead)
- Hardcode secret keys
- Send tokens via insecure channels
Token Types
Common token types:
email_verification- Email verificationpassword_reset- Password resetorg_invitation- Organization invitationsmagic_link- Passwordless loginapi_access- API access tokenswebhook_signature- Webhook signatures
Next Steps
- Trust Tokens Core Concept - Quick overview
- Auth Middleware - Protect routes
- HybridCache - Cache tokens
Need Help?
- 🌐 Notside.com - Professional security implementation
- 📧 Email: contact@notside.com