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/backdooris a valid filename to this handler - No MIME type validation — a
.phpor.shscript uploaded asimage.jpgpasses 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.MaxBytesReaderkills oversized requests at the network level — before your handler logic runsParseMultipartFormwith a memory cap prevents large files from exhausting heap memory- The
header.Sizecheck is a final safeguard against multipart boundary manipulation
Tuning your limits:
| Use Case | Suggested Limit |
|---|---|
| Avatar / profile photo | 2–5 MB |
| Document (PDF, DOCX) | 10–25 MB |
| Video (short clips) | 100–500 MB |
| General purpose | 10 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 traversalphoto.jpg.php— double extension to fool naïve filtersCON,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
freshclamon 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
.phpor.pyfiles 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 Case | Recommended TTL |
|---|---|
| Inline image display | 1–4 hours |
| Document download | 15–30 minutes |
| API response (redirect) | 60–300 seconds |
| Email attachment link | 24–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:
| Layer | Check | Status |
|---|---|---|
| Size | http.MaxBytesReader applied before parse | ✓ |
| Size | ParseMultipartForm with memory cap | ✓ |
| Size | header.Size double-checked after parse | ✓ |
| MIME | Magic-byte detection — not extension, not Content-Type header | ✓ |
| MIME | Explicit allowlist — not a blocklist | ✓ |
| Filename | Server generates storage key — never uses client filename for paths | ✓ |
| Filename | Original name sanitized before storing in DB or headers | ✓ |
| Malware | ClamAV or equivalent scans before storage | ✓ |
| Storage | Files go to private object storage — not local disk | ✓ |
| Storage | Bucket has no public access policy | ✓ |
| Storage | Server-side encryption enabled | ✓ |
| Access | Files served via short-lived signed URLs only | ✓ |
| Access | Authorization check before generating signed URL | ✓ |
| Logging | Rejections and scan results logged for auditing | ✓ |
Summary
| Practice | What It Prevents |
|---|---|
| File size limits | Disk exhaustion, DoS via large uploads |
| MIME validation | Executing disguised scripts, serving malicious content |
| Filename sanitization | Path traversal, XSS via filename, double-extension bypasses |
| Malware scanning | Storing and distributing infected files |
| Object storage | Direct execution, disk-based attacks, scalability issues |
| Signed URLs | Unauthorized 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.