One-Time Token Plugin
Secure verification tokens for sensitive operations like account deletion and organization management.
Protect sensitive operations with one-time verification codes. When users delete accounts, remove organizations, or perform other high-security actions, they must verify with a 6-digit code sent to their email.
This page is part of the Authentication documentation.
Overview
The one-time token plugin generates cryptographically secure, single-use verification codes. Unlike session-based verification, these codes:
- Expire after a short time (default: 10 minutes)
- Can only be used once
- Are stored as hashed values for security
- Require the user to have email access
Use Cases
The one-time token plugin is used for:
- Account deletion - Verify before permanently deleting user account
- Organization deletion - Verify before removing organization and all data
- Sensitive profile changes - Email changes, phone number updates
- Any operation requiring email-based confirmation
Configuration
Default Settings
| Setting | Value | Description |
|---|---|---|
| Expiration | 10 minutes | Token validity period |
| Storage | Hashed | Tokens stored as SHA-256 hashes |
| Length | 6 digits | Code format (100000-999999) |
Environment Variables
apps/web/.env.local
# Optional: customize expiration time in minutesOTP_EXPIRES_MINUTES=10Implementation
The plugin is configured in packages/better-auth/src/plugins/one-time-token.ts:
import { oneTimeToken } from 'better-auth/plugins/one-time-token';const MIN_CODE = 100_000;const CODE_RANGE = 900_000;const UINT32_RANGE = 2 ** 32;const UINT32_LIMIT = Math.floor(UINT32_RANGE / CODE_RANGE) * CODE_RANGE;function generateSixDigitCode() { const crypto = globalThis.crypto; if (!crypto?.getRandomValues) { throw new Error('Web Crypto unavailable'); } const buffer = new Uint32Array(1); let value: number; // Rejection sampling for uniform distribution do { crypto.getRandomValues(buffer); value = buffer[0]!; } while (value >= UINT32_LIMIT); return String((value % CODE_RANGE) + MIN_CODE);}const OTP_EXPIRES_MINUTES = parseInt( process.env.OTP_EXPIRES_MINUTES ?? '10', 10,);export const oneTimeTokenPlugin = oneTimeToken({ expiresIn: OTP_EXPIRES_MINUTES, generateToken: async () => { const code = generateSixDigitCode(); if (process.env.NODE_ENV === 'development') { console.log(`[DEV] Verification code: ${code}`); } return code; }, storeToken: 'hashed',});Cryptographic Security
The code generation uses:
- Web Crypto API -
crypto.getRandomValues()for true randomness - Rejection sampling - Ensures uniform distribution across the code range
- No modulo bias - Codes are evenly distributed (100000-999999)
Development Mode
In development (NODE_ENV=development), generated codes are logged to the console:
[DEV] Verification code: 847293This allows testing sensitive operations without email configuration.
Token Lifecycle
- User initiates sensitive operation (e.g., delete account)
- Server generates a 6-digit code
- Code is hashed and stored with expiration
- Email is sent to user with the code
- User enters code in verification dialog
- Server verifies the hashed code
- If valid, operation proceeds
- Code is invalidated immediately
Client Usage
Account Deletion Flow
The delete account dialog demonstrates the typical flow:
import { authClient } from '@kit/better-auth/client';// Step 1: Request verification emailconst handleRequestCode = async () => { await authClient.user.sendDeleteAccountVerification(); setStep('verify');};// Step 2: Verify code and deleteconst handleVerifyAndDelete = async (code: string) => { const result = await authClient.user.deleteUser({ token: code, }); if (result.error) { console.error('Invalid or expired code'); return; } // Account deleted successfully router.push('/');};Generic Token Operations
import { authClient } from '@kit/better-auth/client';// Request a one-time tokenawait authClient.oneTimeToken.sendToken({ type: 'custom-operation',});// Verify the tokenconst result = await authClient.oneTimeToken.verifyToken({ token: '847293', type: 'custom-operation',});if (result.data?.valid) { // Proceed with operation}Security Features
Hashed Storage
Tokens are stored as SHA-256 hashes in the database. Even if the database is compromised:
- Attackers cannot retrieve the original codes
- Codes cannot be reused from stolen hashes
- Each code maps to a unique hash
Single Use
Each token is invalidated immediately after successful verification. Replay attacks are prevented.
Short Expiration
The 10-minute default expiration limits the attack window. If a code is intercepted, it becomes useless quickly.
Cryptographically Secure Generation
Using the Web Crypto API ensures:
- No predictable patterns in codes
- Uniform distribution (no bias toward certain numbers)
- Resistance to statistical analysis
Error Handling
| Error | Description | User Action |
|---|---|---|
| Token expired | Code was not used within expiration window | Request a new code |
| Token invalid | Code doesn't match or was already used | Check for typos, request new code |
| Token not found | No pending verification for this operation | Start the operation again |
In the Application
The delete account flow is implemented in packages/account/ui/src/components/delete-account-dialog.tsx:
- User clicks "Delete Account"
- Warning dialog explains consequences
- User confirms and email is sent
- User enters 6-digit code from email
- Account is permanently deleted
Common Pitfalls
- Code expiration: Users must enter codes within 10 minutes. UI should show countdown or warning.
- Email delays: Slow email delivery can cause codes to expire. Monitor delivery times.
- Copy-paste errors: Ensure UI allows easy code entry. Consider auto-focus and input validation.
- Multiple requests: New requests invalidate previous codes. UI should indicate this to users.
Frequently Asked Questions
Why use one-time tokens instead of just password confirmation?
Can I extend the expiration time?
What operations use one-time tokens?
Are one-time tokens the same as OTPs?
What happens if a user never uses their code?
Next: Admin Plugin →