PRIV ProtocolPRIV Docs
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_7Gx9K2mNpQrStUvWxYzA1B3C

SHA-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 original

Key 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?

PropertyBenefit
One-wayOriginal key cannot be recovered from hash
Collision resistantDifferent keys produce different hashes
FastEfficient for request validation
Web Crypto APIAvailable 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?

FeaturePurpose
SaltPrevents rainbow table attacks
Cost factorAdjustable computation time
Time-safe comparisonPrevents 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 TypeLifetimeStorage
Access token15 minutesMemory only
Refresh token7 daysHttpOnly cookie
API session24 hoursServer-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


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
  })
}
AttributeValuePurpose
httpOnlytruePrevent XSS access
securetrueHTTPS only
sameSitestrictPrevent CSRF
path/api/authLimit 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

EndpointLimitWindow
/api/auth/login5 requests1 minute
/api/auth/register3 requests1 hour
/api/auth/reset-password3 requests1 hour
/api/v1/events1000 requests1 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
}

Resources