Skip to content

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?

FeatureJWTPASETO
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

bash
npm install paseto
bash
yarn add paseto
bash
bun add paseto

Setup

Step 1: Database Schema

Create the trust_tokens table for tracking tokens:

typescript
// 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:

bash
bun run migrate

Step 2: Generate Key Pair

Create a script to generate Ed25519 keys:

typescript
// 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:

bash
bun run scripts/generate-paseto-keys.ts

Keep 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:

env
# ============================================
# 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/mydb

Security Best Practices

  • Never commit .env files 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:

typescript
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:

typescript
// 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:

  1. PASETO signature - Cryptographic verification
  2. Expiration check - Time-based validation
  3. Redis status - Fast cache lookup
  4. Database status - Persistent validation
  5. User ownership - Verify user exists and email matches
  6. Resource validation - Ensure token hasn't been used

Example 2: Password Reset

Complete implementation with security best practices:

typescript
// 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

typescript
// 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:

  1. PASETO Signature - Ed25519 cryptographic verification
  2. Expiration Check - Token must not be expired
  3. Redis Status - Token must not be invalidated
  4. Database Status - Token must exist in database
  5. User Ownership - User must own the resource
  6. 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 verification
  • password_reset - Password reset
  • org_invitation - Organization invitations
  • magic_link - Passwordless login
  • api_access - API access tokens
  • webhook_signature - Webhook signatures

Next Steps

Need Help?

Released under the MIT License.