homeprojectsblogabout
  • github

  • email

  • x / twitter

  • linkedin

  • personal docs

  • rss

© 2026 Yehezkiel Wiradhika

Secure File Uploads in Go

8/31/2026

Accepting file uploads is one of the easiest ways to introduce a critical vulnerability into your application. Here's how to do it right.

Most Go upload tutorials show you how to call r.FormFile() and save a file to disk. That's the tutorial version. The production version looks very different.

File uploads are a high-risk attack surface. A single oversight — accepting any file type, trusting the client-provided filename, skipping size checks — can lead to path traversal attacks, server-side execution of malicious scripts, denial-of-service through disk exhaustion, or worse.

In this article, I'll walk through every layer of a secure file upload pipeline in Go. Each section builds on the last, so by the end you'll have a complete, production-ready handler you can adapt for your own services.


The Unsafe Baseline (What Not to Do)

Before we build the secure version, let's look at what a naïve implementation looks like — and why it's dangerous:

Dangerous — don't ship this (bad practice):

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    file, header, _ := r.FormFile("file")
    defer file.Close()

    dst, _ := os.Create("/uploads/" + header.Filename)
    defer dst.Close()

    io.Copy(dst, file)
    fmt.Fprintf(w, "uploaded: %s", header.Filename)
}

This handler has multiple critical flaws:

  • No size limit — a 10 GB file will happily exhaust your disk
  • Trusts the client filename — ../../etc/cron.d/backdoor is a valid filename to this handler
  • No MIME type validation — a .php or .sh script uploaded as image.jpg passes right through
  • No malware scanning — malicious files go straight to storage
  • Saves to local disk — not scalable, and one misconfigured web server away from executing uploaded files
  • Ignores all errors — silent failures, no observability

Let's fix every one of these, layer by layer.


1. File Size Limits — Starve the Flood

The rule: Enforce size limits at the transport layer, before the file touches memory or disk. Never rely on client-reported content length.

An attacker can send a multipart request with a spoofed Content-Length header and stream gigabytes of data into your server. Without a server-side limit, you'll run out of memory or disk before the request completes.

Go gives you two tools here:

import (
    "net/http"
)

const (
    MaxFileSize    = 10 << 20  // 10 MB
    MaxMemoryParse = 5 << 20   // 5 MB held in memory; rest goes to temp disk
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    // step 1: wrap the body with a hard size cap at the transport level
    // this cuts the connection if the client streams more than the limit.
    r.Body = http.MaxBytesReader(w, r.Body, MaxFileSize)

    // step 2: parse the multipart form with a memory cap
    // files larger than MaxMemoryParse spill to temp files on disk.
    if err := r.ParseMultipartForm(MaxMemoryParse); err != nil {
        http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
        return
    }

    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "invalid file", http.StatusBadRequest)
        return
    }
    defer file.Close()

    // step 3: double-check the actual size after parsing
    // defends against multipart parsing edge cases.
    if header.Size > MaxFileSize {
        http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
        return
    }

    // ...
}

Why three checks?

  • http.MaxBytesReader kills oversized requests at the network level — before your handler logic runs
  • ParseMultipartForm with a memory cap prevents large files from exhausting heap memory
  • The header.Size check is a final safeguard against multipart boundary manipulation

Tuning your limits:

Use CaseSuggested Limit
Avatar / profile photo2–5 MB
Document (PDF, DOCX)10–25 MB
Video (short clips)100–500 MB
General purpose10 MB default

Set limits appropriate to your use case. Don't use 500 MB as a blanket limit because "some users might upload videos" — give each upload endpoint its own tailored cap.


2. MIME Validation — Don't Trust the Extension

The rule: Never trust the file extension or the Content-Type header sent by the client. Detect the actual MIME type by inspecting the file's magic bytes.

This is the most commonly skipped step. A client can name any file photo.jpg — the extension is cosmetic. Attackers routinely rename .php, .sh, or .html files with image extensions to bypass naïve filters.

The correct approach reads the first 512 bytes of the file and uses the OS's MIME detection — which looks at magic bytes (the actual binary signature embedded in the file format):

import (
    "fmt"
    "io"
    "net/http"
)

// allowlist of permitted MIME types — be explicit, not permissive
var allowedMIMETypes = map[string]bool{
    "image/jpeg":      true,
    "image/png":       true,
    "image/gif":       true,
    "image/webp":      true,
    "application/pdf": true,
}

func detectMIME(file io.ReadSeeker) (string, error) {
    // read the first 512 bytes — enough for http.DetectContentType
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        return "", fmt.Errorf("read for MIME detection: %w", err)
    }

    // reset so subsequent reads start from the beginning
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        return "", fmt.Errorf("seek reset: %w", err)
    }

    return http.DetectContentType(buf[:n]), nil
}

func validateMIME(file io.ReadSeeker) error {
    mimeType, err := detectMIME(file)
    if err != nil {
        return err
    }

    if !allowedMIMETypes[mimeType] {
        return fmt.Errorf("file type not permitted: %s", mimeType)
    }

    return nil
}

Use it in your handler:

if err := validateMIME(file); err != nil {
    http.Error(w, "unsupported file type", http.StatusUnsupportedMediaType)
    return
}

What about the Content-Type header?

Reject it as the sole source of truth. Use it only as a supplemental signal — if the client-reported type and the detected type disagree, that's a red flag worth logging.

clientType := header.Header.Get("Content-Type")
detectedType, _ := detectMIME(file)

if clientType != detectedType {
    log.Printf("MIME mismatch: client=%s detected=%s", clientType, detectedType)
    // treat detected type as authoritative
}

For more advanced MIME detection, consider github.com/gabriel-vasile/mimetype — it has a significantly larger signature database than Go's standard library and correctly identifies hundreds of additional formats:

import "github.com/gabriel-vasile/mimetype"

mtype, err := mimetype.DetectReader(file)
if err != nil {
    return fmt.Errorf("MIME detection: %w", err)
}

// reset after detection
file.Seek(0, io.SeekStart)

if !allowedMIMETypes[mtype.String()] {
    return fmt.Errorf("file type not permitted: %s", mtype.String())
}

3. Filename Sanitization — Never Trust the Client Name

The rule: Treat the client-provided filename as hostile input. Sanitize it aggressively, or generate a new one entirely.

The client filename is arbitrary. Consider what happens if an attacker sends:

  • ../../etc/cron.d/backdoor — path traversal
  • photo.jpg.php — double extension to fool naïve filters
  • CON, NUL, PRN — Windows reserved names that crash programs
  • <script>alert(1)</script>.jpg — XSS via filename in a download header
  • A 4096-character filename — buffer overflows in older code

The safest approach is to ignore the client filename entirely and generate a new one:

import (
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "mime"
    "path/filepath"
    "regexp"
    "strings"
)

// generateStorageKey creates a cryptographically random storage key. 
// the original filename is preserved only for display purposes — never for storage paths.
func generateStorageKey(mimeType string) (string, error) {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        return "", fmt.Errorf("generate key: %w", err)
    }

    exts, _ := mime.ExtensionsByType(mimeType)
    ext := ".bin"
    if len(exts) > 0 {
        ext = exts[0]
    }

    return hex.EncodeToString(b) + ext, nil
}

If you must preserve the original filename (e.g., for user-facing downloads), sanitize it strictly:

var (
    // only allow alphanumerics, dots, dashes, and underscores
    safeChars   = regexp.MustCompile(`[^a-zA-Z0-9._\-]`)
    // catch double extensions like photo.jpg.php
    multiExt    = regexp.MustCompile(`\.[^.]+\.[^.]+$`)
)

func sanitizeFilename(name string) (string, error) {
    // strip any path components
    name = filepath.Base(name)

    // remove or replace unsafe characters
    name = safeChars.ReplaceAllString(name, "_")

    // reject double extensions
    if multiExt.MatchString(name) {
        return "", fmt.Errorf("invalid filename: multiple extensions")
    }

    // enforce length limit
    if len(name) > 128 {
        name = name[:128]
    }

    // reject empty or dot-only names
    if name == "" || strings.Trim(name, ".") == "" {
        return "", fmt.Errorf("invalid filename")
    }

    return name, nil
}

My recommendation: Use generateStorageKey for the storage path unconditionally. Store the sanitized original name separately in your database for display and download headers. The storage key and the display name should never be the same value.

type UploadedFile struct {
    StorageKey      string // random, for object storage — never user-provided
    OriginalName    string // sanitized, for Content-Disposition headers
    MIMEType        string
    SizeBytes       int64
    UploadedAt      time.Time
}

4. Malware Scanning — Inspect Before You Store

The rule: Scan file contents for malware before storing or serving them — especially for documents and archives that may contain macros or nested executables.

MIME validation tells you what type of file it is. Malware scanning tells you whether the file is dangerous regardless of type. A valid .pdf can contain embedded JavaScript. A legitimate .docx can contain macros that execute on open.

The most common integration for Go services is ClamAV via the clamd TCP socket:

import (
    "fmt"
    "io"
    "net"
    "strings"
)

type ClamAVScanner struct {
    address string // e.g., "localhost:3310"
}

func NewClamAVScanner(address string) *ClamAVScanner {
    return &ClamAVScanner{address: address}
}

// scan streams the file content to clamd and returns an error if a threat is detected.
func (s *ClamAVScanner) Scan(r io.Reader) error {
    conn, err := net.Dial("tcp", s.address)
    if err != nil {
        return fmt.Errorf("connect to clamd: %w", err)
    }
    defer conn.Close()

    // INSTREAM command: clamd reads a chunked byte stream
    fmt.Fprintf(conn, "nINSTREAM\n")

    buf := make([]byte, 1024)
    for {
        n, err := r.Read(buf)
        if n > 0 {
            // send chunk length as 4-byte big-endian, then chunk data
            size := []byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}
            conn.Write(size)
            conn.Write(buf[:n])
        }
        if err == io.EOF {
            break
        }
        if err != nil {
            return fmt.Errorf("read file for scan: %w", err)
        }
    }

    // send zero-length chunk to signal end of stream
    conn.Write([]byte{0, 0, 0, 0})

    // read clamd's verdict
    result := make([]byte, 100)
    n, err := conn.Read(result)
    if err != nil {
        return fmt.Errorf("read scan result: %w", err)
    }

    response := strings.TrimSpace(string(result[:n]))
    if strings.Contains(response, "FOUND") {
        return fmt.Errorf("malware detected: %s", response)
    }

    return nil
}

Use it in your handler:

// reset file to beginning before scanning
if _, err := file.Seek(0, io.SeekStart); err != nil {
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

if err := scanner.Scan(file); err != nil {
    log.Printf("scan failed or threat detected: %v", err)
    http.Error(w, "file rejected", http.StatusUnprocessableEntity)
    return
}

Deployment considerations:

  • Run ClamAV as a sidecar container in Kubernetes or a separate service — not in the same process
  • Keep virus definitions updated via freshclam on a cron schedule
  • For cloud-native alternatives, look at Google Cloud DLP, AWS Macie, or managed scanning APIs like MetaDefender
  • For document-specific threats, consider unoconv + ClamAV to convert and scan Office files before storing

What if scanning is too slow?

For high-throughput services, scan asynchronously: accept the upload, store it in a quarantine bucket, scan in the background, then move to the public bucket on a clean result. Store a scan_status field in your database (pending, clean, infected) and don't serve the file until it's clean.


5. Object Storage — Get Files Off Your Server

The rule: Never store uploaded files on your application server's local filesystem. Use object storage (S3, GCS, Azure Blob) instead.

Storing files locally creates several serious problems:

  • Execution risk — a misconfigured web server may serve .php or .py files as executables
  • Scalability — files don't replicate across horizontal scale-out instances
  • Durability — no built-in redundancy; a disk failure loses everything
  • Security — your application process has direct filesystem access

Object storage solves all of these. Here's a production-ready upload to AWS S3 using the v2 SDK:

import (
    "context"
    "fmt"
    "io"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Uploader struct {
    client *s3.Client
    bucket string
}

func NewS3Uploader(ctx context.Context, bucket string) (*S3Uploader, error) {
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return nil, fmt.Errorf("load AWS config: %w", err)
    }

    return &S3Uploader{
        client: s3.NewFromConfig(cfg),
        bucket: bucket,
    }, nil
}

type UploadInput struct {
    Key         string    // storage path — use your generated random key
    Body        io.Reader
    ContentType string
    ContentDisposition string // controls how browsers handle the file on download
}

func (u *S3Uploader) Upload(ctx context.Context, in UploadInput) error {
    _, err := u.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket:             aws.String(u.bucket),
        Key:                aws.String(in.Key),
        Body:               in.Body,
        ContentType:        aws.String(in.ContentType),
        ContentDisposition: aws.String(in.ContentDisposition),

        // CRITICAL: prevent the browser from sniffing the MIME type
        // always use the server-specified ContentType
        ServerSideEncryption: "AES256",

        // restrict all object access
        // no public URLs: access is granted only via signed URLs (see next section)
    })
    if err != nil {
        return fmt.Errorf("s3 put object %s: %w", in.Key, err)
    }

    return nil
}

Bucket configuration checklist:

✓ Block all public access (no public bucket policies, no ACLs)
✓ Enable server-side encryption (SSE-S3 or SSE-KMS)
✓ Enable versioning (recover from accidental overwrites)
✓ Enable access logging (audit who fetched what)
✓ Set a lifecycle policy to delete unconfirmed uploads after 24h
✓ Use a dedicated IAM role with only s3:PutObject and s3:GetObject
✓ Never grant s3:DeleteObject to your application role — use a separate cleanup process

Separate buckets for upload and serve:

A pattern I use in production: upload to a private quarantine bucket, scan and validate, then copy the clean file to a separate assets bucket. The application never serves directly from the quarantine bucket.

client → POST /upload → quarantine bucket → scanner → assets bucket → signed URL

This ensures that even if your validation logic has a bug, unvalidated files are never reachable.


6. Signed URLs — Serve Files Without Exposing Your Bucket

The rule: Never make your storage bucket public. Generate short-lived, cryptographically signed URLs to grant temporary, scoped read access to specific files.

Signed URLs solve the "how do users download their files?" problem without opening your bucket to the world. A signed URL embeds the bucket, key, expiry, and a cryptographic signature — it's valid only for the specified file and only until it expires.

import (
    "context"
    "fmt"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/aws/aws-sdk-go-v2/service/s3/presign"
)

type URLSigner struct {
    presignClient *presign.Client
    bucket        string
    defaultTTL    time.Duration
}

func NewURLSigner(s3Client *s3.Client, bucket string) *URLSigner {
    return &URLSigner{
        presignClient: presign.NewPresignClient(s3Client),
        bucket:        bucket,
        defaultTTL:    15 * time.Minute,
    }
}

// SignedDownloadURL generates a pre-signed GET URL for a specific object
// the URL expires after ttl and cannot be used for any other object.
func (u *URLSigner) SignedDownloadURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
    if ttl <= 0 {
        ttl = u.defaultTTL
    }

    req, err := u.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: aws.String(u.bucket),
        Key:    aws.String(key),
    }, presign.WithPresignExpires(ttl))
    if err != nil {
        return "", fmt.Errorf("presign get object %s: %w", key, err)
    }

    return req.URL, nil
}

// SignedUploadURL generates a pre-signed PUT URL for direct client uploads
// this pattern lets clients upload directly to S3 without routing through your server.
func (u *URLSigner) SignedUploadURL(ctx context.Context, key, contentType string) (string, error) {
    req, err := u.presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
        Bucket:      aws.String(u.bucket),
        Key:         aws.String(key),
        ContentType: aws.String(contentType),
    }, presign.WithPresignExpires(10*time.Minute))
    if err != nil {
        return "", fmt.Errorf("presign put object %s: %w", key, err)
    }

    return req.URL, nil
}

Serving a file through a signed URL:

func (h *Handler) GetFileURL(w http.ResponseWriter, r *http.Request) {
    fileID := r.PathValue("id")

    // 1. look up the file record in your database
    record, err := h.db.GetFile(r.Context(), fileID)
    if err != nil {
        http.Error(w, "file not found", http.StatusNotFound)
        return
    }

    // 2. verify the requesting user owns or has access to this file
    claims := auth.FromContext(r.Context())
    if record.OwnerID != claims.UserID {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }

    // 3. generate a short-lived signed URL
    url, err := h.signer.SignedDownloadURL(r.Context(), record.StorageKey, 15*time.Minute)
    if err != nil {
        log.Printf("sign URL for file %s: %v", fileID, err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // 4. return the URL — client uses it directly
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "url":        url,
        "expires_in": "900", // seconds
    })
}

TTL guidance:

Use CaseRecommended TTL
Inline image display1–4 hours
Document download15–30 minutes
API response (redirect)60–300 seconds
Email attachment link24–72 hours

Keep TTLs short. If a URL leaks (in a log, a shared screen, a browser history), a short TTL limits the blast radius. For sensitive documents, generate a fresh URL on every access request rather than caching it.


Putting It All Together

Here's a complete, production-ready handler composing all six layers:

func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // layer 1: enforce size limit at transport level
    r.Body = http.MaxBytesReader(w, r.Body, MaxFileSize)
    if err := r.ParseMultipartForm(MaxMemoryParse); err != nil {
        http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
        return
    }

    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "invalid file", http.StatusBadRequest)
        return
    }
    defer file.Close()

    if header.Size > MaxFileSize {
        http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
        return
    }

    // layer 2: validate actual MIME type via magic bytes
    if err := validateMIME(file); err != nil {
        http.Error(w, "unsupported file type", http.StatusUnsupportedMediaType)
        return
    }

    // layer 3: generate a safe storage key; sanitize original name for display
    mimeType, _ := detectMIME(file)
    storageKey, err := generateStorageKey(mimeType)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    displayName, err := sanitizeFilename(header.Filename)
    if err != nil {
        http.Error(w, "invalid filename", http.StatusBadRequest)
        return
    }

    // layer 4: scan for malware before storing
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if err := h.scanner.Scan(file); err != nil {
        log.Printf("scan rejected file %s: %v", displayName, err)
        http.Error(w, "file rejected", http.StatusUnprocessableEntity)
        return
    }

    // layer 5: upload to object storage (quarantine bucket)
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if err := h.uploader.Upload(ctx, UploadInput{
        Key:                storageKey,
        Body:               file,
        ContentType:        mimeType,
        ContentDisposition: fmt.Sprintf(`attachment; filename="%s"`, displayName),
    }); err != nil {
        log.Printf("upload failed: %v", err)
        http.Error(w, "upload failed", http.StatusInternalServerError)
        return
    }

    // persist file record to database
    record, err := h.db.CreateFile(ctx, &FileRecord{
        StorageKey:  storageKey,
        OriginalName: displayName,
        MIMEType:    mimeType,
        SizeBytes:   header.Size,
        OwnerID:     auth.FromContext(ctx).UserID,
        ScanStatus:  "clean",
        UploadedAt:  time.Now().UTC(),
    })
    if err != nil {
        log.Printf("persist file record: %v", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // layer 6: return a short-lived signed URL for immediate access
    signedURL, err := h.signer.SignedDownloadURL(ctx, storageKey, 15*time.Minute)
    if err != nil {
        log.Printf("sign URL: %v", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "id":          record.ID,
        "name":        displayName,
        "download_url": signedURL,
        "expires_in":  900,
    })
}

Security Checklist

Before you ship your upload endpoint, run through this:

LayerCheckStatus
Sizehttp.MaxBytesReader applied before parse✓
SizeParseMultipartForm with memory cap✓
Sizeheader.Size double-checked after parse✓
MIMEMagic-byte detection — not extension, not Content-Type header✓
MIMEExplicit allowlist — not a blocklist✓
FilenameServer generates storage key — never uses client filename for paths✓
FilenameOriginal name sanitized before storing in DB or headers✓
MalwareClamAV or equivalent scans before storage✓
StorageFiles go to private object storage — not local disk✓
StorageBucket has no public access policy✓
StorageServer-side encryption enabled✓
AccessFiles served via short-lived signed URLs only✓
AccessAuthorization check before generating signed URL✓
LoggingRejections and scan results logged for auditing✓

Summary

PracticeWhat It Prevents
File size limitsDisk exhaustion, DoS via large uploads
MIME validationExecuting disguised scripts, serving malicious content
Filename sanitizationPath traversal, XSS via filename, double-extension bypasses
Malware scanningStoring and distributing infected files
Object storageDirect execution, disk-based attacks, scalability issues
Signed URLsUnauthorized access, hotlinking, bucket exposure

File upload security isn't a single check — it's a pipeline. Each layer catches what the previous one missed. Skip one, and you've left a gap an attacker will find.

On this page