Security
Authentication Security
Secure API key hashing, JWT token handling, and authentication best practices.
Overview
PRIV implements secure authentication at multiple levels to protect your integration and user data.
API Key Hashing
Keys are hashed with SHA-256 before storage. Original keys are never stored.
JWT Security
Short-lived tokens with secure signing and automatic refresh.
API Key Security
Key Generation
PRIV API keys are cryptographically secure random strings:
// How PRIV generates API keys (conceptual)
import crypto from 'crypto'
function generateApiKey(type: 'publishable' | 'secret'): string {
const prefix = type === 'publishable' ? 'pk' : 'sk'
const env = process.env.NODE_ENV === 'production' ? 'live' : 'test'
const random = crypto.randomBytes(24).toString('base64url')
return `${prefix}_${env}_${random}`
}
// Example output: pk_live_7Gx9K2mNpQrStUvWxYzA1B3CSHA-256 Hashing
API keys are hashed before storage using SHA-256:
/**
* Hash an API key using SHA-256
* Used for secure storage and comparison
*/
async function hashApiKey(key: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(key)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
// Usage
const hashedKey = await hashApiKey('pk_live_xxxxx')
// Store hashedKey in database, never the originalKey Validation
On each request, the provided key is hashed and compared:
async function validateApiKey(providedKey: string): Promise<boolean> {
// Hash the provided key
const hashedProvided = await hashApiKey(providedKey)
// Look up in database
const storedKey = await db.apiKeys.findUnique({
where: { hash: hashedProvided }
})
if (!storedKey) {
return false
}
// Check if key is active and not expired
if (storedKey.revoked || storedKey.expiresAt < new Date()) {
return false
}
return true
}Why SHA-256?
| Property | Benefit |
|---|---|
| One-way | Original key cannot be recovered from hash |
| Collision resistant | Different keys produce different hashes |
| Fast | Efficient for request validation |
| Web Crypto API | Available in Edge runtime |
Password Security
For user accounts, PRIV uses bcrypt for password hashing:
Password Requirements
import { z } from 'zod'
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number')
.regex(/[^A-Za-z0-9]/, 'Password must contain a special character')
// Validate password
function validatePassword(password: string): { valid: boolean; errors: string[] } {
const result = passwordSchema.safeParse(password)
if (result.success) {
return { valid: true, errors: [] }
}
return {
valid: false,
errors: result.error.errors.map(e => e.message),
}
}Password Hashing
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12 // Adjust based on security needs
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}Why bcrypt?
| Feature | Purpose |
|---|---|
| Salt | Prevents rainbow table attacks |
| Cost factor | Adjustable computation time |
| Time-safe comparison | Prevents timing attacks |
JWT Token Security
Token Structure
interface PrivJWT {
// Standard claims
iss: string // Issuer: 'priv.io'
sub: string // Subject: User ID
aud: string // Audience: 'priv-api'
exp: number // Expiration timestamp
iat: number // Issued at timestamp
jti: string // Unique token ID
// Custom claims
email: string
wallet_address?: string
role: 'user' | 'admin'
permissions: string[]
}Token Lifetimes
| Token Type | Lifetime | Storage |
|---|---|---|
| Access token | 15 minutes | Memory only |
| Refresh token | 7 days | HttpOnly cookie |
| API session | 24 hours | Server-side |
Secure Token Handling
// Server-side token generation
import jwt from 'jsonwebtoken'
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET!
function generateTokens(user: User) {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
ACCESS_TOKEN_SECRET,
{
expiresIn: '15m',
issuer: 'priv.io',
audience: 'priv-api',
}
)
const refreshToken = jwt.sign(
{ sub: user.id },
REFRESH_TOKEN_SECRET,
{
expiresIn: '7d',
issuer: 'priv.io',
}
)
return { accessToken, refreshToken }
}Token Verification
import jwt from 'jsonwebtoken'
function verifyAccessToken(token: string): PrivJWT | null {
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: 'priv.io',
audience: 'priv-api',
}) as PrivJWT
return decoded
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
console.log('Token expired')
} else if (error instanceof jwt.JsonWebTokenError) {
console.log('Invalid token')
}
return null
}
}Token Refresh Flow
Cookie Security
Refresh tokens are stored in secure HttpOnly cookies:
import { cookies } from 'next/headers'
async function setRefreshToken(token: string) {
const cookieStore = await cookies()
cookieStore.set('refresh_token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // Prevent CSRF
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/api/auth', // Limited to auth endpoints
})
}Cookie Attributes
| Attribute | Value | Purpose |
|---|---|---|
httpOnly | true | Prevent XSS access |
secure | true | HTTPS only |
sameSite | strict | Prevent CSRF |
path | /api/auth | Limit scope |
Session Management
Session Storage
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
})
interface Session {
userId: string
createdAt: number
lastActivity: number
userAgent: string
ipAddress: string
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = crypto.randomUUID()
const session: Session = {
userId,
createdAt: Date.now(),
lastActivity: Date.now(),
userAgent: request.headers.get('user-agent') || '',
ipAddress: request.headers.get('x-forwarded-for') || '',
}
await redis.set(`session:${sessionId}`, JSON.stringify(session), {
ex: 24 * 60 * 60, // 24 hours
})
return sessionId
}
async function validateSession(sessionId: string): Promise<Session | null> {
const data = await redis.get(`session:${sessionId}`)
if (!data) return null
const session = JSON.parse(data as string) as Session
// Update last activity
session.lastActivity = Date.now()
await redis.set(`session:${sessionId}`, JSON.stringify(session), {
ex: 24 * 60 * 60,
})
return session
}Session Revocation
async function revokeSession(sessionId: string): Promise<void> {
await redis.del(`session:${sessionId}`)
}
async function revokeAllUserSessions(userId: string): Promise<void> {
// Get all session keys for user
const keys = await redis.keys(`session:*`)
for (const key of keys) {
const session = await redis.get(key)
if (session) {
const parsed = JSON.parse(session as string) as Session
if (parsed.userId === userId) {
await redis.del(key)
}
}
}
}Rate Limiting
Protect authentication endpoints from brute force attacks:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
analytics: true,
})
async function loginHandler(request: Request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return new Response('Too many login attempts', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
})
}
// Process login...
}Rate Limits by Endpoint
| Endpoint | Limit | Window |
|---|---|---|
/api/auth/login | 5 requests | 1 minute |
/api/auth/register | 3 requests | 1 hour |
/api/auth/reset-password | 3 requests | 1 hour |
/api/v1/events | 1000 requests | 1 minute |
Best Practices
Do
- Use HTTPS for all authentication endpoints
- Hash API keys with SHA-256 before storage
- Use bcrypt for password hashing
- Implement rate limiting on auth endpoints
- Use short-lived access tokens (15 min)
- Store refresh tokens in HttpOnly cookies
- Validate token signatures and claims
- Log authentication events for auditing
Don't
- Store plaintext API keys or passwords
- Use weak hashing algorithms (MD5, SHA1)
- Include sensitive data in JWT payload
- Use long-lived access tokens
- Store tokens in localStorage
- Skip token expiration checks
- Log passwords or tokens
Security Checklist
// Pre-deployment security verification
async function verifyAuthSecurity() {
const checks = {
// API key hashing
apiKeyHashingEnabled: await checkApiKeyHashing(),
// Password requirements
passwordPolicyEnabled: validatePassword('weak').valid === false,
// Token configuration
accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY === '15m',
refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY === '7d',
// Cookie security
cookieSecure: process.env.NODE_ENV === 'production',
cookieHttpOnly: true,
cookieSameSite: 'strict',
// Rate limiting
rateLimitEnabled: await checkRateLimit(),
}
const allPassed = Object.values(checks).every(v => v === true)
if (!allPassed) {
console.error('Security checks failed:', checks)
throw new Error('Authentication security requirements not met')
}
return true
}