Security
Spritz implements comprehensive security measures to protect users from malicious content, XSS attacks, and impersonation attempts.
URL Security
User-generated content often includes URLs (profile links, widget embeds, social links). Spritz validates all URLs to prevent security vulnerabilities.
Dangerous URL Detection
The isDangerousUrl function blocks potentially malicious URL schemes:
export function isDangerousUrl(url: string): boolean {
if (!url) return true;
const trimmed = url.trim().toLowerCase();
// Block dangerous protocols
if (trimmed.startsWith('javascript:')) return true;
if (trimmed.startsWith('data:') && !trimmed.startsWith('data:image/')) return true;
if (trimmed.startsWith('vbscript:')) return true;
if (trimmed.startsWith('file:')) return true;
// Block URLs with encoded dangerous characters
const decoded = decodeURIComponent(trimmed);
if (decoded.startsWith('javascript:')) return true;
return false;
}
Blocked protocols:
javascript:- Prevents XSS attacksdata:- Blocked except fordata:image/(base64 images)vbscript:- Legacy but still dangerousfile:- Prevents local file access
URL Sanitization
All URLs are sanitized before use:
export function sanitizeUrl(url: string | undefined | null): string | null {
if (!url) return null;
const trimmed = url.trim();
if (isDangerousUrl(trimmed)) {
console.warn('[Security] Blocked dangerous URL:', trimmed.substring(0, 50));
return null;
}
if (!isSafeProtocol(trimmed)) {
console.warn('[Security] Blocked non-http(s) URL:', trimmed.substring(0, 50));
return null;
}
return trimmed;
}
Social Platform Validation
Social links are validated against known domains to prevent phishing:
const SOCIAL_DOMAINS: Record<string, string[]> = {
twitter: ['twitter.com', 'x.com'],
github: ['github.com'],
linkedin: ['linkedin.com', 'www.linkedin.com'],
instagram: ['instagram.com', 'www.instagram.com'],
youtube: ['youtube.com', 'www.youtube.com', 'youtu.be'],
tiktok: ['tiktok.com', 'www.tiktok.com'],
discord: ['discord.gg', 'discord.com'],
telegram: ['t.me', 'telegram.me'],
farcaster: ['warpcast.com'],
website: [], // Allow any for generic website
};
export function validateSocialUrl(platform: string, url: string): boolean {
const allowedDomains = SOCIAL_DOMAINS[platform.toLowerCase()];
// If no domain restriction, just check it's safe
if (!allowedDomains || allowedDomains.length === 0) {
return !isDangerousUrl(url) && isSafeProtocol(url);
}
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase().replace(/^www\./, '');
return allowedDomains.some(domain =>
hostname === domain || hostname === `www.${domain}`
);
} catch {
return false;
}
}
Embed URL Validation
Video and music embeds are restricted to trusted platforms:
const EMBED_DOMAINS: Record<string, string[]> = {
spotify: ['open.spotify.com'],
youtube: ['youtube.com', 'www.youtube.com', 'youtube-nocookie.com'],
vimeo: ['player.vimeo.com', 'vimeo.com'],
loom: ['loom.com', 'www.loom.com'],
twitter: ['twitter.com', 'x.com', 'platform.twitter.com'],
instagram: ['instagram.com', 'www.instagram.com'],
};
Image URL Sanitization
Image URLs support both regular URLs and base64 data URLs:
export function sanitizeImageUrl(
url: string | undefined | null,
strict: boolean = false
): string | null {
if (!url) return null;
const trimmed = url.trim();
// Allow data:image URLs (for base64 encoded images)
if (trimmed.startsWith('data:image/')) {
// Limit size to prevent abuse
if (trimmed.length > 100000) {
console.warn('[Security] Blocked oversized data URL');
return null;
}
return trimmed;
}
if (isDangerousUrl(trimmed)) return null;
if (!isSafeProtocol(trimmed)) return null;
return trimmed;
}
Trusted image domains (optional strict mode):
- CDNs: Unsplash, Imgur, Cloudinary, jsDelivr
- Social: Twitter, GitHub avatars
- NFT: IPFS, OpenSea, Alchemy
- Spritz: app.spritz.chat, spritz.chat
Video URL Parsing
Video URLs are converted to safe embed URLs:
export function sanitizeVideoUrl(
platform: 'youtube' | 'vimeo' | 'loom',
input: string
): { embedUrl: string; videoId: string } | null {
// YouTube: Extract video ID and create nocookie embed
// Returns: https://www.youtube-nocookie.com/embed/{videoId}
// Vimeo: Extract video ID
// Returns: https://player.vimeo.com/video/{videoId}
// Loom: Extract share ID
// Returns: https://www.loom.com/embed/{shareId}
}
Safe External Link Props
When rendering external links, use safe props:
export function getSafeExternalLinkProps(url: string | null) {
if (!url || isDangerousUrl(url)) {
return {
href: '#',
onClick: (e: React.MouseEvent) => e.preventDefault(),
};
}
return {
href: url,
target: '_blank',
rel: 'noopener noreferrer nofollow',
};
}
Always include:
target="_blank"- Opens in new tabrel="noopener noreferrer nofollow"- Prevents tab hijacking and referrer leaks
Input Sanitization
All user inputs are sanitized before storage:
import { sanitizeInput, INPUT_LIMITS } from "@/lib/sanitize";
// Sanitize with length limits
const sanitizedName = sanitizeInput(username, INPUT_LIMITS.USERNAME);
Input Limits
| Field | Max Length |
|---|---|
| Username | 20 characters |
| Display Name | 50 characters |
| Bio | 500 characters |
| Message | 4000 characters |
| Agent Personality | 1000 characters |
Reserved Usernames
To prevent impersonation and scams, certain usernames are reserved:
Blocked Categories
Brand names:
spritz,spritzapp,spritzchat,spritz_support, etc.
Official roles:
admin,administrator,support,moderator,staff,team
Security terms:
security,root,sysadmin,webmaster
Financial terms:
wallet,vault,treasury,payment,billing
Authority titles:
ceo,cto,founder,developer,owner
Scam prevention:
giveaway,airdrop,prize,verify,claim
Known figures:
vitalik,satoshi,elon
Blocked Patterns
Prefixes that are blocked:
spritz_*,official_*,support_*,admin_*,mod_*,team_*
Suffixes that are blocked:
*_official,*_support,*_admin,*_verified
Implementation
function isReservedUsername(username: string): boolean {
const normalized = username.toLowerCase();
// Direct match against reserved list
if (RESERVED_USERNAMES.has(normalized)) {
return true;
}
// Check blocked prefixes
const reservedPrefixes = ["spritz_", "official_", "support_"];
for (const prefix of reservedPrefixes) {
if (normalized.startsWith(prefix)) return true;
}
// Check blocked suffixes
const reservedSuffixes = ["_official", "_support", "_admin", "_verified"];
for (const suffix of reservedSuffixes) {
if (normalized.endsWith(suffix)) return true;
}
return false;
}
Contract Address Validation
For NFT widgets and Web3 features:
export function isValidContractAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
Centralized Logging
Spritz uses a centralized logger to control log output:
import { createLogger } from "@/lib/logger";
const authLogger = createLogger('Auth');
// In production: only errors logged
// In development: all logs shown
// Debug mode: localStorage.setItem('spritz_debug', 'true')
authLogger.debug('Debug message'); // Only in dev or debug mode
authLogger.info('Info message'); // Only in dev or debug mode
authLogger.warn('Warning'); // Always logged
authLogger.error('Error'); // Always logged
Pre-configured Loggers
import {
authLogger,
chatLogger,
callLogger,
walletLogger,
apiLogger
} from "@/lib/logger";
Enabling Debug Mode
In browser console:
localStorage.setItem('spritz_debug', 'true');
// Reload page to see debug logs
Session Security
Session Timeout & Lock Screen
Spritz implements automatic session locking to protect users who leave their devices unattended.
Default Timeouts:
| Scenario | Timeout |
|---|---|
| Active session (no activity) | 15 minutes |
| App backgrounded | 1 minute |
| Warning before lock | 1 minute |
import { useInactivityMonitor, useSessionLock } from '@/hooks/useInactivityMonitor';
function App() {
const { lock, unlock, isLocked, lockReason } = useSessionLock();
const { resetActivity, isWarningShown, timeRemaining } = useInactivityMonitor({
timeout: 15 * 60 * 1000, // 15 minutes
backgroundTimeout: 60 * 1000, // 1 minute when backgrounded
warningThreshold: 60 * 1000, // Show warning 1 min before lock
onInactive: () => lock('inactivity'),
onWarning: () => console.log('Session will lock soon'),
});
// Show lock screen when session is locked
if (isLocked) {
return <SessionLockScreen onUnlock={unlock} reason={lockReason} />;
}
// Show warning banner when about to lock
if (isWarningShown && timeRemaining) {
return <div>Session locking in {timeRemaining}s</div>;
}
return <MainApp />;
}
Unlock Options:
- Re-authenticate with passkey
- Sign with connected wallet
- Manual unlock (if recently authenticated)
CSRF Protection
Sessions include origin validation:
// Validate request origin matches expected domains
const allowedOrigins = [
'https://app.spritz.chat',
'https://spritz.chat',
];
if (!allowedOrigins.includes(request.headers.get('origin'))) {
return new Response('Invalid origin', { status: 403 });
}
HttpOnly Cookies
Session tokens are stored in HttpOnly cookies:
response.cookies.set('spritz_session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
Token Expiration
- Session tokens: 7 days
- Passkey challenges: 5 minutes
- Verification codes: 10 minutes
- SIWE nonces: 5 minutes
Nonce Verification (Fail Closed)
SIWE/SIWS nonces are stored in Redis and verified atomically (one-time use):
// Production behavior: FAIL CLOSED
if (!redis && isProduction) {
console.error("CRITICAL: Redis not configured in production!");
return false; // Reject auth request
}
// Development: Allow without verification (with warning)
if (!redis) {
console.warn("Redis not configured - nonce verification disabled (dev mode)");
return true;
}
Security Requirements:
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENmust be set in production- Without Redis, all authentication requests are rejected in production
- This prevents replay attacks when nonce storage is unavailable
| Environment | Redis Available | Behavior |
|---|---|---|
| Production | ✅ Yes | Normal verification |
| Production | ❌ No | Reject all auth |
| Development | ✅ Yes | Normal verification |
| Development | ❌ No | Allow (with warning) |
Cryptographically Secure Randomness
Spritz uses crypto.getRandomValues() for all security-sensitive random generation. Never use Math.random() for security purposes.
Available Functions
import {
secureRandomString,
secureVerificationCode,
secureRandomHex,
secureUUID,
secureRecoveryToken,
secureInviteCode,
} from '@/lib/secureRandom';
Verification Codes
6-digit numeric codes with rejection sampling to avoid modulo bias:
const code = secureVerificationCode();
// Returns: "847291" (6 digits, always padded)
Uses rejection sampling: if the random number falls in the "bias zone" (0.02% of cases), it retries for uniform distribution.
Invite Codes
URL-safe codes without ambiguous characters (0, O, I, 1, L excluded):
const invite = secureInviteCode(8);
// Returns: "KJ7HNPRQ"
Recovery Tokens
URL-safe base64 tokens for password reset, etc.:
const token = secureRecoveryToken(32);
// Returns: "aB3dEfGh1jKlMnOp..."
Hex Strings
For cryptographic keys and hashes:
const hex = secureRandomHex(32);
// Returns: "0x8a7b3c..." (64 hex chars = 32 bytes)
Usage Guidelines
| Use Case | Function | Example |
|---|---|---|
| Email verification | secureVerificationCode() | "847291" |
| Invite codes | secureInviteCode(8) | "KJ7HNPRQ" |
| Password reset | secureRecoveryToken(32) | URL-safe string |
| Transaction nonces | secureRandomHex(32) | "0x..." |
| UUIDs | secureUUID() | RFC 4122 UUID |
DO use Math.random() for:
- UI animations
- Non-security log IDs
- Shuffle for display only
Best Practices
For Widget Content
- Always sanitize URLs before rendering
- Validate embed domains for video/audio content
- Limit data URL sizes for images
- Use safe link props for external links
For User Input
- Apply length limits to all text inputs
- Sanitize HTML (strip dangerous tags)
- Validate usernames against reserved list
- Normalize addresses (lowercase for EVM)
For API Endpoints
- Verify session for authenticated routes
- Check origin headers for CSRF protection
- Rate limit to prevent abuse
- Log security events for audit trail
Security Headers
Recommended headers for production:
const securityHeaders = {
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'",
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
};
Security Checklist
Before deploying, verify:
-
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENare set - JWT secret is cryptographically strong (32+ bytes)
- HttpOnly cookies are enabled for sessions
- CORS is configured for allowed origins only
- Rate limiting is enabled on API routes
- RLS policies are enabled on sensitive tables
- No sensitive data in client-side code or logs
- Security headers are configured in production
Reporting Security Issues
If you discover a security vulnerability:
- Do NOT open a public issue
- Email security@spritz.chat with details
- Include steps to reproduce
- Allow 90 days for fix before disclosure
We take security seriously and will acknowledge reports within 48 hours.
Related Documentation
- Authentication - SIWE/SIWS and passkey flows
- Spritz Wallets - Wallet security
- Social Vaults - Multi-sig security
- Database Schema - Data model and constraints
- Troubleshooting - Debug common issues