Skip to main content

Overview

iStory implements a layered security model addressing 34 audit findings across API routes, smart contracts, and infrastructure. The architecture follows defense-in-depth principles with multiple security layers.
The security architecture was hardened in Phase 1.5 (January 2026) based on a comprehensive security audit. Current risk score: B+ (production-ready with ongoing hardening).

Security Layers

1

Layer 0: Security Headers

CSP, X-Frame-Options, CORS via next.config.mjs
2

Layer 1: Rate Limiting

In-memory middleware with route-specific limits
3

Layer 2: Bearer Token Authentication

JWT validation via lib/auth.ts
4

Layer 3: Input Validation

File size, MIME type, text length limits
5

Layer 4: Ownership Verification

Users can only modify their own resources
6

Client: Local Vault Encryption

AES-256-GCM encryption at rest in IndexedDB

Authentication System

Dual Authentication

iStory supports two authentication methods:

Wallet Authentication

Web3 wallet signatures with nonce-based replay prevention

OAuth Authentication

Google OAuth via Supabase with account linking

Wallet Authentication Flow

1

Connect Wallet

User connects wallet via RainbowKit
2

Request Nonce

Client fetches server-generated nonce
const response = await fetch(`/api/auth/nonce?address=${address}`);
const { nonce } = await response.json();
3

Sign Message

User signs message containing nonce + timestamp
const message = `Sign in to iStory\nNonce: ${nonce}\nTimestamp: ${Date.now()}`;
const signature = await signMessage({ message });
4

Verify Signature

Server verifies signature and nonce (one-time use, 5-min expiry)
import { verifyMessage } from "viem";

const isValid = await verifyMessage({
  address: walletAddress,
  message,
  signature,
});

// Check nonce is fresh and unused
if (!isNonceValid(nonce)) {
  return NextResponse.json({ error: "Invalid nonce" }, { status: 401 });
}
5

Issue JWT

Server issues custom JWT stored in localStorage
app/api/auth/nonce/route.ts
import { randomUUID } from "crypto";

interface NonceEntry {
  nonce: string;
  timestamp: number;
}

const nonceStore = new Map<string, NonceEntry>();
const NONCE_EXPIRY = 5 * 60 * 1000; // 5 minutes

export async function GET(req: NextRequest) {
  const address = req.nextUrl.searchParams.get("address");
  const nonce = randomUUID();
  
  nonceStore.set(address!.toLowerCase(), {
    nonce,
    timestamp: Date.now(),
  });
  
  return NextResponse.json({ nonce });
}
Security Properties:
  • Nonces are UUIDs (128-bit entropy)
  • 5-minute expiry prevents replay attacks
  • One-time use (deleted after verification)
  • Cleaned up periodically to prevent memory leaks

OAuth Authentication Flow

1

Initiate OAuth

User clicks “Sign in with Google”
2

Google Redirect

User authorizes on Google OAuth consent screen
3

Callback Handler

Server validates redirect URL against whitelist
const ALLOWED_REDIRECTS = [
  "/profile",
  "/library",
  "/record",
  "/social",
];

const redirectTo = url.searchParams.get("redirect");
if (redirectTo && !ALLOWED_REDIRECTS.includes(redirectTo)) {
  return NextResponse.redirect(new URL("/", request.url));
}
4

Session Creation

Supabase creates session, client stores JWT

Account Linking

Users can link their Google account and wallet for unified access.
Step 1: Initiate Link (requires authentication)
app/api/auth/initiate-link/route.ts
import { createHmac } from "crypto";

const hmacToken = createHmac("sha256", process.env.JWT_SECRET!)
  .update(`${userId}:${walletAddress}:${timestamp}`)
  .digest("hex");

return NextResponse.json({ linkToken: hmacToken });
Step 2: Verify Link (requires wallet signature + linking token)
app/api/auth/link-account/route.ts
const isTokenValid = verifyHmacToken(linkToken, userId, walletAddress);
const isSignatureValid = await verifyMessage({ address, message, signature });

if (isTokenValid && isSignatureValid) {
  // Update user record with linked wallet
  await supabase
    .from("users")
    .update({ wallet_address: walletAddress })
    .eq("id", userId);
}
Security Properties:
  • HMAC-signed tokens prove wallet ownership
  • Timing-safe token comparison prevents timing attacks
  • Both accounts must be authenticated before linking

Authorization & Access Control

API Route Protection

All API routes use shared authentication middleware:
import { validateAuthOrReject, isAuthError } from "@/lib/auth";

export async function POST(req: NextRequest) {
  // Step 1: Validate authentication
  const authResult = await validateAuthOrReject(req);
  if (isAuthError(authResult)) return authResult;
  const authenticatedUserId = authResult;
  
  // Step 2: Parse request body
  const body = await req.json();
  
  // Step 3: Verify ownership (if modifying resources)
  const { data: resource } = await supabase
    .from("stories")
    .select("author_id")
    .eq("id", body.storyId)
    .single();
  
  if (resource.author_id !== authenticatedUserId) {
    return NextResponse.json(
      { error: "Forbidden" },
      { status: 403 }
    );
  }
  
  // Step 4: Perform operation
  // ...
}

Authentication Helpers

/**
 * Validate Bearer token from request.
 * Returns user ID if valid, null otherwise.
 * Supports both Supabase JWT and custom wallet JWT.
 */
export async function validateAuth(
  request: NextRequest
): Promise<string | null>;

/**
 * Validate Bearer token and return user ID, or return 401 response.
 * Use this when the route requires authentication.
 */
export async function validateAuthOrReject(
  request: NextRequest
): Promise<string | NextResponse>;

/**
 * Verify that the authenticated user owns the given wallet address.
 * Looks up the users table to confirm wallet_address matches.
 */
export async function validateWalletOwnership(
  userId: string,
  walletAddress: string
): Promise<boolean>;

/**
 * Helper: check if a value is a NextResponse (i.e., auth failed).
 */
export function isAuthError(
  result: string | NextResponse
): result is NextResponse;

Rate Limiting

Route-Specific Limits

function getRateLimit(pathname: string): number {
  // CRE callback: higher limit (multiple DON nodes)
  if (pathname === "/api/cre/callback") return 30;
  
  // AI endpoints: expensive API calls
  if (pathname.startsWith("/api/ai/")) return 10;
  
  // Auth endpoints: prevent brute force
  if (pathname.startsWith("/api/auth/")) return 20;
  
  // Email endpoint: prevent spam
  if (pathname === "/api/email/send") return 5;
  
  // Default for all other API routes
  if (pathname.startsWith("/api/")) return 60;
  
  // Non-API routes: no rate limiting
  return 0;
}
Rate limiting uses an in-memory store (Map) with automatic cleanup. For production scale, consider Redis with Upstash.

Rate Limit Response

{
  "error": "Too many requests. Please try again later."
}

Input Validation

File Upload Validation

app/api/ai/transcribe/route.ts
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
const ALLOWED_MIME_TYPES = [
  "audio/mpeg",
  "audio/mp3",
  "audio/wav",
  "audio/webm",
  "audio/ogg",
];

if (!file || file.size > MAX_FILE_SIZE) {
  return NextResponse.json(
    { error: "File too large (max 25MB)" },
    { status: 400 }
  );
}

if (!ALLOWED_MIME_TYPES.includes(file.type)) {
  return NextResponse.json(
    { error: "Invalid file type" },
    { status: 400 }
  );
}
app/api/ipfs/upload/route.ts
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const ALLOWED_MIME_TYPES = [
  "image/jpeg",
  "image/png",
  "image/gif",
  "image/webp",
  "application/json",
];

if (file.size > MAX_FILE_SIZE) {
  return NextResponse.json(
    { error: "File too large (max 50MB)" },
    { status: 413 }
  );
}

if (!ALLOWED_MIME_TYPES.includes(file.type)) {
  return NextResponse.json(
    { error: "Unsupported file type" },
    { status: 415 }
  );
}

Text Input Validation

const MAX_TEXT_LENGTH = 50_000; // 50K characters

if (!text || text.length > MAX_TEXT_LENGTH) {
  return NextResponse.json(
    { error: "Text too long (max 50K chars)" },
    { status: 400 }
  );
}

Security Headers

Next.js Configuration

async headers() {
  return [
    {
      source: "/(.*)",
      headers: [
        {
          key: "X-Frame-Options",
          value: "DENY",
        },
        {
          key: "X-Content-Type-Options",
          value: "nosniff",
        },
        {
          key: "Referrer-Policy",
          value: "strict-origin-when-cross-origin",
        },
        {
          key: "X-XSS-Protection",
          value: "1; mode=block",
        },
        {
          key: "Permissions-Policy",
          value: "camera=(), microphone=(self), geolocation=()",
        },
        {
          key: "Content-Security-Policy",
          value: [
            "default-src 'self'",
            "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
            "img-src 'self' data: https: blob:",
            "font-src 'self' data: https://fonts.gstatic.com",
            "connect-src 'self' https://*.supabase.co https://*.elevenlabs.io https://sepolia.base.org",
          ].join("; "),
        },
      ],
    },
  ];
}
HeaderValuePurpose
X-Frame-OptionsDENYPrevents clickjacking attacks
X-Content-Type-OptionsnosniffPrevents MIME type sniffing
Referrer-Policystrict-origin-when-cross-originControls referrer information leakage
X-XSS-Protection1; mode=blockEnables browser XSS filter
Permissions-Policycamera=(), microphone=(self)Restricts browser features
Content-Security-PolicyMultiple directivesPrevents XSS, data injection attacks

Database Security

Row Level Security (RLS)

iStory uses Supabase RLS policies for defense-in-depth protection:
-- Enable RLS on all tables
ALTER TABLE stories ENABLE ROW LEVEL SECURITY;
ALTER TABLE story_metadata ENABLE ROW LEVEL SECURITY;
ALTER TABLE weekly_reflections ENABLE ROW LEVEL SECURITY;

-- Users can read their own stories
CREATE POLICY "Users can read their own stories"
  ON stories
  FOR SELECT
  USING (auth.uid()::text = author_id::text);

-- Users can read public stories
CREATE POLICY "Users can read public stories"
  ON stories
  FOR SELECT
  USING (is_public = true);

-- Users can only modify their own stories
CREATE POLICY "Users can update their own stories"
  ON stories
  FOR UPDATE
  USING (auth.uid()::text = author_id::text)
  WITH CHECK (auth.uid()::text = author_id::text);
Admin Client UsageAPI routes use the admin client which bypasses RLS. Always add explicit authorization checks before performing operations:
const { data: story } = await supabase
  .from("stories")
  .select("author_id")
  .eq("id", storyId)
  .single();

if (story.author_id !== authenticatedUserId) {
  return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

Smart Contract Security

OpenZeppelin Libraries

All smart contracts use audited OpenZeppelin libraries:
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

contract EStoryToken is ERC20, ERC20Burnable, AccessControl, Pausable {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
    uint256 public constant MAX_SUPPLY = 100_000_000 * 10 ** 18;
    
    function mint(address to, uint256 amount) 
        external 
        onlyRole(MINTER_ROLE) 
        whenNotPaused 
    {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
    }
}

Security Features by Contract

EStoryToken

  • MAX_SUPPLY cap (100M)
  • Pausable mechanism
  • Role-based minting
  • Burn functionality

StoryProtocol

  • SafeERC20 transfers
  • ReentrancyGuard
  • Pausable mechanism
  • AccessControl roles

StoryNFT

  • Mint fee (0.001 ETH)
  • ERC2981 royalties
  • AccessControl roles
  • Secure withdrawal
// EStoryToken Roles
DEFAULT_ADMIN_ROLE  // Can grant/revoke roles
MINTER_ROLE         // Can mint new tokens (backend)
PAUSER_ROLE         // Can pause transfers (emergency)

// StoryProtocol Roles
DEFAULT_ADMIN_ROLE  // Can grant/revoke roles
PAUSER_ROLE         // Can pause protocol (emergency)

// StoryNFT Roles
DEFAULT_ADMIN_ROLE  // Can grant/revoke roles
MINTER_ROLE         // Can mint NFTs (backend)
Single Admin Risk: One address holds all roles across contracts. For production mainnet, use multi-sig (Gnosis Safe) and separate role holders.

Local Vault Encryption

iStory includes a client-side encryption vault for private stories:
import { webcrypto } from "crypto";

// AES-256-GCM encryption
export async function encryptString(
  plaintext: string,
  dek: CryptoKey
): Promise<string> {
  const iv = webcrypto.getRandomValues(new Uint8Array(12));
  const encrypted = await webcrypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    dek,
    new TextEncoder().encode(plaintext)
  );
  
  return JSON.stringify({
    iv: Buffer.from(iv).toString("base64"),
    data: Buffer.from(encrypted).toString("base64"),
  });
}

Encryption Architecture

1

PIN → KEK

User PIN → PBKDF2 (100K iterations) → KEK (Key Encryption Key)
2

KEK wraps DEK

KEK encrypts DEK (Data Encryption Key) using AES-KW
3

DEK encrypts data

DEK encrypts story content using AES-256-GCM
4

Vault locked

DEK held in-memory only while unlocked, cleared on sign-out
Security Properties:
  • PIN never stored or transmitted
  • DEK never leaves browser memory
  • AES-256-GCM provides authenticated encryption
  • PBKDF2 with 100K iterations prevents brute force

Error Handling

Secure Error Responses

try {
  // Operation logic
} catch (error) {
  // Log detailed error server-side
  console.error("[ROUTE_NAME] Error:", error);
  
  // Return generic message to client
  return NextResponse.json(
    { error: "Internal server error" },
    { status: 500 }
  );
}
Never leak internal details in error responses:
  • error: error.message (exposes database errors, stack traces)
  • error: "Internal server error" (generic message)

Secrets Management

Environment Variables

SUPABASE_SERVICE_ROLE_KEY=eyJ...
GOOGLE_GENERATIVE_AI_API_KEY=AIza...
ELEVENLABS_API_KEY=sk_...
PINATA_JWT=eyJ...
JWT_SECRET=your_random_secret_here
CRON_SECRET=your_cron_secret_here
Best Practices:
  1. Use Vercel environment variables with scoped access
  2. Never commit .env.local to git (.gitignore)
  3. Rotate secrets regularly
  4. Use KMS or hardware wallets for private keys
  5. Separate secrets per environment (dev, staging, prod)
Timing-Safe Secret Comparison:
lib/crypto.ts
import { timingSafeEqual } from "crypto";

export function safeCompare(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
Use for CRON_SECRET and API key verification to prevent timing attacks.

Security Audit Summary

Audit Date: January 30, 2026
Overall Risk Score: B+ (production-ready)
Findings Addressed:
  • ✅ 11 Critical findings (authentication, nonce replay, admin client overuse)
  • ✅ 8 High findings (rate limiting, input validation, RLS policies)
  • ✅ 9 Medium findings (CSRF protection, error disclosure, security headers)
  • ✅ 6 Low findings (timing-safe comparison, gas limits)
Total: 34 findings addressed in Phase 1.5
  1. Authentication: Nonce-based wallet signatures prevent replay attacks
  2. Rate Limiting: Route-specific limits protect against abuse
  3. Input Validation: File size, MIME type, text length checks
  4. RLS Policies: Defense-in-depth for database access
  5. Security Headers: CSP, X-Frame-Options, X-Content-Type-Options
  6. Error Sanitization: Generic error messages prevent information leakage
  7. Timing-Safe Comparison: Prevent timing attacks on secrets
  8. Admin Client Minimization: Explicit authorization checks before operations

What’s Next?

Database Schema

Explore PostgreSQL schema and RLS policies

CRE Integration

Learn about verifiable AI compute

Build docs developers (and LLMs) love