[{"data":1,"prerenderedAt":388},["ShallowReactive",2],{"blog-how-to/image-upload-security":3},{"id":4,"title":5,"body":6,"category":369,"date":370,"dateModified":370,"description":371,"draft":372,"extension":373,"faq":374,"featured":372,"headerVariant":375,"image":374,"keywords":374,"meta":376,"navigation":377,"ogDescription":378,"ogTitle":374,"path":379,"readTime":374,"schemaOrg":380,"schemaType":381,"seo":382,"sitemap":383,"stem":384,"tags":385,"twitterCard":386,"__hash__":387},"blog/blog/how-to/image-upload-security.md","How to Secure Image Uploads",{"type":7,"value":8,"toc":349},"minimark",[9,13,17,21,30,35,51,55,58,93,97,123,139,155,171,193,230,234,237,243,249,253,257,263,267,273,277,283,287,293,299,306,312,318,324,330],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-secure-image-uploads",[18,19,20],"p",{},"Handle user images safely without exposing your app to attacks",[22,23,24,27],"tldr",{},[18,25,26],{},"TL;DR (20 minutes)",[18,28,29],{},"Validate images using magic bytes, not just extensions. Check dimensions before processing to prevent decompression bombs. Strip EXIF metadata to protect user privacy. Reprocess images through sharp to generate clean files. Use cloud storage with a CDN for secure delivery.",[31,32,34],"h2",{"id":33},"prerequisites","Prerequisites",[36,37,38,42,45,48],"ul",{},[39,40,41],"li",{},"Node.js 18+ installed",[39,43,44],{},"A Next.js, Express, or similar Node.js project",[39,46,47],{},"Cloud storage account (S3, R2, or similar) - recommended",[39,49,50],{},"npm or yarn package manager",[31,52,54],{"id":53},"why-image-security-matters","Why Image Security Matters",[18,56,57],{},"Images can contain more than meets the eye: embedded scripts, malicious metadata, polyglot files that are valid as multiple formats, and decompression bombs that crash your server. EXIF data can leak user location and device information.",[59,60,61,67],"danger-box",{},[18,62,63],{},[64,65,66],"strong",{},"Real Attack Examples:",[36,68,69,75,81,87],{},[39,70,71,74],{},[64,72,73],{},"EXIF data leak:"," Photos contain GPS coordinates revealing user's home address",[39,76,77,80],{},[64,78,79],{},"Polyglot attack:"," A file that's both a valid JPEG and valid JavaScript",[39,82,83,86],{},[64,84,85],{},"Decompression bomb:"," A 42KB PNG that expands to 4.5GB when decompressed",[39,88,89,92],{},[64,90,91],{},"XSS in SVG:"," SVG files can contain embedded JavaScript",[31,94,96],{"id":95},"step-by-step-guide","Step-by-Step Guide",[98,99,101,106,109,120],"step",{"number":100},"1",[102,103,105],"h3",{"id":104},"install-required-libraries","Install required libraries",[18,107,108],{},"Install sharp for image processing and file-type for validation:",[110,111,116],"pre",{"className":112,"code":114,"language":115},[113],"language-text","npm install sharp file-type uuid\n","text",[117,118,114],"code",{"__ignoreMap":119},"",[18,121,122],{},"Sharp is a high-performance image processing library that handles resizing, format conversion, and metadata stripping.",[98,124,126,130,133],{"number":125},"2",[102,127,129],{"id":128},"create-image-validation-utilities","Create image validation utilities",[18,131,132],{},"Validate images thoroughly before processing:",[110,134,137],{"className":135,"code":136,"language":115},[113],"// lib/image-validation.ts\nimport { fileTypeFromBuffer } from 'file-type';\nimport sharp from 'sharp';\n\nconst ALLOWED_IMAGE_TYPES = [\n  'image/jpeg',\n  'image/png',\n  'image/webp',\n  'image/gif',\n] as const;\n\nconst MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB\nconst MAX_DIMENSION = 4096; // Prevent decompression bombs\nconst MIN_DIMENSION = 10;\n\ninterface ImageValidationResult {\n  valid: boolean;\n  error?: string;\n  mimeType?: string;\n  width?: number;\n  height?: number;\n}\n\nexport async function validateImage(buffer: Buffer): Promise\u003CImageValidationResult> {\n  // Check file size first (fast check)\n  if (buffer.length > MAX_FILE_SIZE) {\n    return {\n      valid: false,\n      error: `Image exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`,\n    };\n  }\n\n  if (buffer.length === 0) {\n    return { valid: false, error: 'File is empty' };\n  }\n\n  // Detect actual file type using magic bytes\n  const detectedType = await fileTypeFromBuffer(buffer);\n\n  if (!detectedType) {\n    return { valid: false, error: 'Could not determine file type' };\n  }\n\n  // Check if it's an allowed image type\n  if (!ALLOWED_IMAGE_TYPES.includes(detectedType.mime as any)) {\n    return {\n      valid: false,\n      error: `File type ${detectedType.mime} is not allowed. Allowed: JPEG, PNG, WebP, GIF`,\n    };\n  }\n\n  // Get image dimensions (also validates it's a real image)\n  try {\n    const metadata = await sharp(buffer).metadata();\n\n    if (!metadata.width || !metadata.height) {\n      return { valid: false, error: 'Could not read image dimensions' };\n    }\n\n    // Check for decompression bombs\n    if (metadata.width > MAX_DIMENSION || metadata.height > MAX_DIMENSION) {\n      return {\n        valid: false,\n        error: `Image dimensions exceed maximum of ${MAX_DIMENSION}x${MAX_DIMENSION}`,\n      };\n    }\n\n    if (metadata.width \u003C MIN_DIMENSION || metadata.height \u003C MIN_DIMENSION) {\n      return {\n        valid: false,\n        error: `Image dimensions must be at least ${MIN_DIMENSION}x${MIN_DIMENSION}`,\n      };\n    }\n\n    return {\n      valid: true,\n      mimeType: detectedType.mime,\n      width: metadata.width,\n      height: metadata.height,\n    };\n  } catch (error) {\n    return { valid: false, error: 'Invalid or corrupted image file' };\n  }\n}\n",[117,138,136],{"__ignoreMap":119},[98,140,142,146,149],{"number":141},"3",[102,143,145],{"id":144},"create-image-processing-functions","Create image processing functions",[18,147,148],{},"Process images to strip metadata and generate clean files:",[110,150,153],{"className":151,"code":152,"language":115},[113],"// lib/image-processing.ts\nimport sharp from 'sharp';\n\ninterface ProcessedImage {\n  buffer: Buffer;\n  width: number;\n  height: number;\n  format: 'jpeg' | 'png' | 'webp';\n}\n\ninterface ProcessingOptions {\n  maxWidth?: number;\n  maxHeight?: number;\n  quality?: number;\n  format?: 'jpeg' | 'png' | 'webp' | 'original';\n}\n\nexport async function processImage(\n  buffer: Buffer,\n  options: ProcessingOptions = {}\n): Promise\u003CProcessedImage> {\n  const {\n    maxWidth = 1920,\n    maxHeight = 1080,\n    quality = 85,\n    format = 'webp',\n  } = options;\n\n  let sharpInstance = sharp(buffer)\n    // Remove ALL metadata including EXIF, ICC profiles, etc.\n    .rotate() // Auto-rotate based on EXIF before stripping\n    .withMetadata({ orientation: undefined }) // Strip EXIF\n    .removeAlpha(); // Remove alpha channel if not needed\n\n  // Resize if larger than max dimensions\n  sharpInstance = sharpInstance.resize(maxWidth, maxHeight, {\n    fit: 'inside',\n    withoutEnlargement: true,\n  });\n\n  // Convert to desired format\n  let outputBuffer: Buffer;\n  let outputFormat: 'jpeg' | 'png' | 'webp';\n\n  switch (format) {\n    case 'jpeg':\n      outputBuffer = await sharpInstance.jpeg({ quality, mozjpeg: true }).toBuffer();\n      outputFormat = 'jpeg';\n      break;\n    case 'png':\n      outputBuffer = await sharpInstance.png({ quality, compressionLevel: 9 }).toBuffer();\n      outputFormat = 'png';\n      break;\n    case 'webp':\n    default:\n      outputBuffer = await sharpInstance.webp({ quality }).toBuffer();\n      outputFormat = 'webp';\n      break;\n  }\n\n  const metadata = await sharp(outputBuffer).metadata();\n\n  return {\n    buffer: outputBuffer,\n    width: metadata.width!,\n    height: metadata.height!,\n    format: outputFormat,\n  };\n}\n\n// Generate multiple sizes for responsive images\nexport async function generateImageVariants(buffer: Buffer): Promise\u003C{\n  thumbnail: ProcessedImage;\n  medium: ProcessedImage;\n  large: ProcessedImage;\n}> {\n  const [thumbnail, medium, large] = await Promise.all([\n    processImage(buffer, { maxWidth: 150, maxHeight: 150, quality: 80 }),\n    processImage(buffer, { maxWidth: 600, maxHeight: 600, quality: 85 }),\n    processImage(buffer, { maxWidth: 1200, maxHeight: 1200, quality: 90 }),\n  ]);\n\n  return { thumbnail, medium, large };\n}\n\n// Profile-specific processing with square crop\nexport async function processProfileImage(buffer: Buffer): Promise\u003CProcessedImage> {\n  const outputBuffer = await sharp(buffer)\n    .rotate()\n    .withMetadata({ orientation: undefined })\n    .resize(400, 400, {\n      fit: 'cover',\n      position: 'centre',\n    })\n    .webp({ quality: 85 })\n    .toBuffer();\n\n  return {\n    buffer: outputBuffer,\n    width: 400,\n    height: 400,\n    format: 'webp',\n  };\n}\n",[117,154,152],{"__ignoreMap":119},[98,156,158,162,165],{"number":157},"4",[102,159,161],{"id":160},"implement-the-upload-api-route","Implement the upload API route",[18,163,164],{},"Combine validation and processing in your API:",[110,166,169],{"className":167,"code":168,"language":115},[113],"// app/api/images/upload/route.ts\nimport { validateImage } from '@/lib/image-validation';\nimport { processImage, generateImageVariants } from '@/lib/image-processing';\nimport { uploadToStorage } from '@/lib/storage';\nimport { getServerSession } from 'next-auth';\nimport { v4 as uuidv4 } from 'uuid';\n\nexport async function POST(request: Request) {\n  const session = await getServerSession();\n  if (!session?.user) {\n    return Response.json({ error: 'Unauthorized' }, { status: 401 });\n  }\n\n  try {\n    const formData = await request.formData();\n    const file = formData.get('image') as File | null;\n\n    if (!file) {\n      return Response.json({ error: 'No image provided' }, { status: 400 });\n    }\n\n    const buffer = Buffer.from(await file.arrayBuffer());\n\n    // Validate the image\n    const validation = await validateImage(buffer);\n    if (!validation.valid) {\n      return Response.json({ error: validation.error }, { status: 400 });\n    }\n\n    // Process and generate variants\n    const variants = await generateImageVariants(buffer);\n\n    // Generate unique ID for this upload\n    const imageId = uuidv4();\n    const basePath = `images/${session.user.id}/${imageId}`;\n\n    // Upload all variants\n    const [thumbnailUrl, mediumUrl, largeUrl] = await Promise.all([\n      uploadToStorage(variants.thumbnail.buffer, `${basePath}/thumb.webp`, 'image/webp'),\n      uploadToStorage(variants.medium.buffer, `${basePath}/medium.webp`, 'image/webp'),\n      uploadToStorage(variants.large.buffer, `${basePath}/large.webp`, 'image/webp'),\n    ]);\n\n    // Store metadata\n    const image = await db.image.create({\n      data: {\n        id: imageId,\n        userId: session.user.id,\n        originalName: file.name,\n        thumbnailUrl,\n        mediumUrl,\n        largeUrl,\n        width: variants.large.width,\n        height: variants.large.height,\n      },\n    });\n\n    return Response.json({\n      id: image.id,\n      urls: {\n        thumbnail: thumbnailUrl,\n        medium: mediumUrl,\n        large: largeUrl,\n      },\n    });\n  } catch (error) {\n    console.error('Image upload error:', error);\n    return Response.json({ error: 'Upload failed' }, { status: 500 });\n  }\n}\n",[117,170,168],{"__ignoreMap":119},[98,172,174,178,181,187],{"number":173},"5",[102,175,177],{"id":176},"handle-svg-safely-if-needed","Handle SVG safely (if needed)",[18,179,180],{},"SVGs require special handling because they can contain scripts:",[110,182,185],{"className":183,"code":184,"language":115},[113],"// lib/svg-sanitization.ts\nimport DOMPurify from 'isomorphic-dompurify';\n\nconst SVG_ALLOWED_TAGS = [\n  'svg', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline',\n  'rect', 'g', 'defs', 'use', 'symbol', 'text', 'tspan',\n  'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask',\n];\n\nconst SVG_ALLOWED_ATTRS = [\n  'viewBox', 'width', 'height', 'fill', 'stroke', 'stroke-width',\n  'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',\n  'points', 'transform', 'opacity', 'id', 'class', 'clip-path', 'mask',\n  'offset', 'stop-color', 'stop-opacity', 'href', 'xlink:href',\n];\n\nexport function sanitizeSvg(svgString: string): string | null {\n  // First, check for obvious script content\n  if (/\n",[117,186,184],{"__ignoreMap":119},[188,189,190],"warning-box",{},[18,191,192],{},"Recommendation:\nIf possible, convert SVGs to raster images (PNG) on upload. This completely eliminates the XSS risk while preserving the visual content.",[188,194,195,198],{},[18,196,197],{},"Security Checklist",[36,199,200,203,206,209,212,215,218,221,224,227],{},[39,201,202],{},"Validate image type using magic bytes, not file extension",[39,204,205],{},"Check image dimensions before processing to prevent decompression bombs",[39,207,208],{},"Strip ALL EXIF metadata to protect user privacy",[39,210,211],{},"Reprocess images through sharp to generate clean files",[39,213,214],{},"Generate multiple sizes for responsive delivery",[39,216,217],{},"Convert SVGs to raster or sanitize thoroughly",[39,219,220],{},"Use cloud storage with CDN for secure delivery",[39,222,223],{},"Set proper Content-Type headers when serving images",[39,225,226],{},"Implement rate limiting on upload endpoints",[39,228,229],{},"Never serve user-uploaded images from your main domain (use CDN)",[31,231,233],{"id":232},"how-to-verify-it-worked","How to Verify It Worked",[18,235,236],{},"Test your image upload security:",[110,238,241],{"className":239,"code":240,"language":115},[113],"// Test script for image upload security\n\nasync function testImageSecurity() {\n  // Test 1: Valid JPEG\n  const validJpeg = await fetch('/test-images/valid.jpg').then(r => r.arrayBuffer());\n  const result1 = await validateImage(Buffer.from(validJpeg));\n  console.assert(result1.valid, 'Valid JPEG should pass');\n\n  // Test 2: PHP file renamed to .jpg\n  const phpContent = Buffer.from('\u003C?php echo \"hacked\"; ?>');\n  const result2 = await validateImage(phpContent);\n  console.assert(!result2.valid, 'PHP file should be rejected');\n\n  // Test 3: Oversized dimensions (potential bomb)\n  // Create a test image with large dimensions metadata\n  const result3 = await validateImage(hugeImageBuffer);\n  console.assert(!result3.valid, 'Huge image should be rejected');\n\n  // Test 4: Verify EXIF is stripped\n  const imageWithExif = await fetch('/test-images/with-gps.jpg').then(r => r.arrayBuffer());\n  const processed = await processImage(Buffer.from(imageWithExif));\n  const metadata = await sharp(processed.buffer).metadata();\n  console.assert(!metadata.exif, 'EXIF should be stripped');\n\n  // Test 5: SVG with script tag\n  const maliciousSvg = '\u003Csvg>\u003Cscript>alert(\"xss\")\u003C/script>\u003C/svg>';\n  const sanitized = sanitizeSvg(maliciousSvg);\n  console.assert(sanitized === null || !sanitized.includes('script'), 'SVG script should be removed');\n\n  console.log('All tests passed!');\n}\n\n// Run in your test suite\ntestImageSecurity();\n",[117,242,240],{"__ignoreMap":119},[244,245,246],"tip-box",{},[18,247,248],{},"Pro Tip:\nAfter processing an image, use an EXIF viewer tool like\nexiftool\nto verify all metadata has been removed:\nexiftool processed-image.jpg",[31,250,252],{"id":251},"common-errors-and-troubleshooting","Common Errors and Troubleshooting",[102,254,256],{"id":255},"error-sharp-fails-to-process-image","Error: sharp fails to process image",[110,258,261],{"className":259,"code":260,"language":115},[113],"// Problem: Corrupted or unsupported image format\ntry {\n  const processed = await sharp(buffer).toBuffer();\n} catch (error) {\n  // \"Input file contains unsupported image format\"\n}\n\n// Solution: Validate before processing\nconst validation = await validateImage(buffer);\nif (!validation.valid) {\n  return { error: validation.error };\n}\n// Only then process the image\n",[117,262,260],{"__ignoreMap":119},[102,264,266],{"id":265},"error-memory-issues-with-large-images","Error: Memory issues with large images",[110,268,271],{"className":269,"code":270,"language":115},[113],"// Problem: Processing very large images exhausts memory\n\n// Solution: Limit input size and use streaming\nimport sharp from 'sharp';\n\n// Configure sharp to limit memory usage\nsharp.cache({ memory: 50, files: 20, items: 100 });\nsharp.concurrency(1); // Process one at a time\n\n// Validate dimensions before processing\nif (metadata.width > 4096 || metadata.height > 4096) {\n  return { error: 'Image too large to process' };\n}\n",[117,272,270],{"__ignoreMap":119},[102,274,276],{"id":275},"error-exif-rotation-not-applied","Error: EXIF rotation not applied",[110,278,281],{"className":279,"code":280,"language":115},[113],"// Problem: Image appears rotated incorrectly\n\n// Solution: Call rotate() before stripping metadata\nconst processed = await sharp(buffer)\n  .rotate() // Auto-rotate based on EXIF orientation\n  .withMetadata({ orientation: undefined }) // Then strip EXIF\n  .toBuffer();\n",[117,282,280],{"__ignoreMap":119},[102,284,286],{"id":285},"error-animated-gifs-become-static","Error: Animated GIFs become static",[110,288,291],{"className":289,"code":290,"language":115},[113],"// Problem: sharp converts animated GIF to single frame\n\n// Solution: Use dedicated GIF processing for animations\nimport { Jimp } from 'jimp';\n\n// Check if GIF is animated\nconst metadata = await sharp(buffer).metadata();\nif (metadata.pages && metadata.pages > 1) {\n  // Handle animated GIF differently\n  // Or reject animated GIFs:\n  return { error: 'Animated GIFs are not supported' };\n}\n",[117,292,290],{"__ignoreMap":119},[294,295,296],"faq-section",{},[18,297,298],{},"Frequently Asked Questions",[300,301,303],"faq-item",{"question":302},"Why do I need to reprocess images? Can't I just strip EXIF?",[18,304,305],{},"Reprocessing does more than strip EXIF. It regenerates the image pixels, eliminating any malicious content embedded in the image structure (polyglot attacks, steganography). It also normalizes the format and optimizes file size.",[300,307,309],{"question":308},"Is it safe to accept SVG uploads?",[18,310,311],{},"SVGs are risky because they can contain JavaScript. If you must accept SVGs, either sanitize them thoroughly with a strict allowlist, or better yet, convert them to raster images (PNG) on upload.",[300,313,315],{"question":314},"What's a decompression bomb?",[18,316,317],{},"A decompression bomb is a small compressed file that expands to an enormous size when processed. For images, a 42KB PNG could decompress to 4.5GB of pixel data, crashing your server. Always check dimensions before processing.",[300,319,321],{"question":320},"Should I serve images from my main domain?",[18,322,323],{},"No. Serve user-uploaded images from a separate domain or CDN. This prevents cookie theft if an attacker manages to get executable content served as an image (via MIME type confusion).",[300,325,327],{"question":326},"Why convert to WebP?",[18,328,329],{},"WebP offers better compression than JPEG/PNG (25-35% smaller) with comparable quality. It also provides a consistent output format, simplifying your storage and CDN configuration. All modern browsers support it.",[331,332,333,339,344],"related-articles",{},[334,335],"related-card",{"description":336,"href":337,"title":338},"General file upload security best practices","/blog/how-to/file-upload-security","Secure File Uploads",[334,340],{"description":341,"href":342,"title":343},"Prevent XSS through input sanitization","/blog/how-to/sanitize-input","Sanitize User Input",[334,345],{"description":346,"href":347,"title":348},"CSP and Content-Type protection","/blog/how-to/add-security-headers","Add Security Headers",{"title":119,"searchDepth":350,"depth":350,"links":351},2,[352,353,354,362,363],{"id":33,"depth":350,"text":34},{"id":53,"depth":350,"text":54},{"id":95,"depth":350,"text":96,"children":355},[356,358,359,360,361],{"id":104,"depth":357,"text":105},3,{"id":128,"depth":357,"text":129},{"id":144,"depth":357,"text":145},{"id":160,"depth":357,"text":161},{"id":176,"depth":357,"text":177},{"id":232,"depth":350,"text":233},{"id":251,"depth":350,"text":252,"children":364},[365,366,367,368],{"id":255,"depth":357,"text":256},{"id":265,"depth":357,"text":266},{"id":275,"depth":357,"text":276},{"id":285,"depth":357,"text":286},"how-to","2026-01-15","Step-by-step guide to securing image uploads. Image validation, resizing, EXIF metadata removal, storage security, and preventing image-based attacks.",false,"md",null,"yellow",{},true,"Protect your app from malicious images. Validation, resizing, EXIF removal, and secure storage.","/blog/how-to/image-upload-security","[object Object]","HowTo",{"title":5,"description":371},{"loc":379},"blog/how-to/image-upload-security",[],"summary_large_image","xvu6aypKdqLNZ4Ss4Adrg22W4J3Lm8VEye2o1Cqs3lg",1775843928320]