Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
セキュリティ
AWS
アーキテクチャ設計
コスト最適化

Echo file-upload production design: receiving safely with multipart, S3 streaming, presigned URLs, and validation

A guide to implementing file upload at production quality with Go Echo (v5). With real code, it explains: how to use c.FormFile / c.MultipartForm, S3 streaming upload that doesn't load into memory (AWS SDK Go v2), the presigned-URL approach that doesn't route huge files through the API, size limits, MIME validation, path-traversal countermeasures for filenames, and content deduplication.

Published
Reading time
8 min read
Author
友田 陽大
Share

File upload is easy to "get working," but receiving it safely in production is a deep area. Memory overflows with huge files, server files get overwritten with a malicious filename, you're handed an executable with a faked extension, upload bandwidth clogs the server — none of these are prevented by "just receive it" code.

This article is the file-upload chapter of the Go Echo production-operations guide. Starting from Echo v5's multipart API, it implements, in a production-usable form: streaming that doesn't load into memory, the presigned-URL approach that doesn't route huge files through the API, and security validation.

Rules for this article: Echo's API is based on the official documentation (v5, as of June 2026). AWS SDK for Go v2 is updated, so confirm the latest in the official docs. Credentials are premised on an IAM role / environment variables; don't write access keys in code.


1. Basics: receive one file with c.FormFile (streaming)

Echo v5's file reception is c.FormFile("field")*multipart.FileHeader. The point is to stream with io.Copy without reading the whole file into memory.

func (h *UploadHandler) Upload(c *echo.Context) error {
	file, err := c.FormFile("file") // *multipart.FileHeader
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}

	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	dst, err := os.Create(safeName(file.Filename)) // ← ファイル名は必ずサニタイズ(第4章)
	if err != nil {
		return err
	}
	defer dst.Close()

	if _, err = io.Copy(dst, src); err != nil { // ストリーミング:メモリに全部載せない
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"status": "uploaded"})
}

Multiple files are taken from c.MultipartForm().

form, err := c.MultipartForm()
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "invalid multipart form")
}
for _, file := range form.File["files"] { // <input name="files" multiple>
	src, _ := file.Open()
	// ... 同様に io.Copy でストリーミング保存
	src.Close()
}

Why streaming matters: loading all bytes into memory with io.ReadAll consumes 5GB for a 100MB file × 50 concurrent requests, and the process dies on OOM. io.Copy transfers little by little with a fixed-size buffer, so memory usage is constant regardless of file size.


2. Size limit: reject "before reading" with BodyLimit

Reject huge requests by size before starting to read the body. Make the middleware guide's BodyLimit effective on the upload path.

// アップロード用グループにだけ大きめの上限を設定(v5 は整数バイト)
uploads := e.Group("/uploads")
uploads.Use(middleware.BodyLimit(25_000_000)) // 25MB を超えたら 413

BodyLimit is the outermost layer of defense in depth. In addition, validate on the storage side (S3 object size) and with business rules (per-plan limits). Not "protect in one place" but "protect at each layer" is the production practice.


3. Streaming save to S3: AWS SDK Go v2

In production, save uploads not to the server's local disk but to object storage (S3, etc.). AWS SDK for Go v2's manager.Uploader uploads multipart in parallel while accepting an io.Reader, so you can stream the result of file.Open() directly.

import (
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Storage struct {
	uploader *manager.Uploader
	bucket   string
}

func NewS3Storage(client *s3.Client, bucket string) *S3Storage {
	return &S3Storage{uploader: manager.NewUploader(client), bucket: bucket}
}

func (s *S3Storage) Save(ctx context.Context, key, contentType string, body io.Reader) error {
	_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket:      &s.bucket,
		Key:         &key,
		Body:        body,         // io.Reader をそのまま渡す=メモリに載せない
		ContentType: &contentType,
	})
	return err
}

From the handler, pass c.Request().Context() to propagate cancellation/timeout.

func (h *UploadHandler) UploadToS3(c *echo.Context) error {
	file, err := c.FormFile("file")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}
	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	contentType, err := sniffContentType(src) // 第4章:実体から MIME を判定
	if err != nil || !allowedMIME[contentType] {
		return echo.NewHTTPError(http.StatusUnsupportedMediaType, "unsupported file type")
	}

	key := "uploads/" + uuid.NewString() + extByMIME(contentType) // ファイル名は自前で決める
	if err := h.storage.Save(c.Request().Context(), key, contentType, src); err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"key": key})
}

Don't write credentials: configure s3.Client from the SDK's default credential chain (ECS task role / EC2 instance role / environment variables). Don't hardcode access keys in code or environment variables; the correct answer is to grant least privilege (just PutObject on the target bucket) with an IAM role (deployment secret management).


4. Security: three mandatory validations

An upload is a mass of external input. Observe at minimum these three.

4.1 Don't trust the filename (path-traversal countermeasure)

file.Filename is a string the client freely decided. Using a value like ../../etc/passwd or ../app.go directly as a path lets the server's arbitrary file be overwritten. The server decides the filename.

// ❌ 危険:クライアント由来の名前をそのままパスに
dst, _ := os.Create("/data/" + file.Filename) // ../ でディレクトリを抜けられる

// ✅ 安全:自前で命名(UUID)。元の名前はメタデータとして「値」で保持
key := uuid.NewString() + filepath.Ext(file.Filename) // 拡張子だけは検証後に流用
// 表示用の元ファイル名は DB のカラムに保存し、パスには使わない

4.2 Look at MIME by "substance," not extension

A .png extension can lie. Pass the file's first 512 bytes to http.DetectContentType, judge the substance's Content-Type, and match it against an allowlist.

var allowedMIME = map[string]bool{
	"image/jpeg": true, "image/png": true, "application/pdf": true,
}

func sniffContentType(rs io.ReadSeeker) (string, error) {
	buf := make([]byte, 512)
	n, err := rs.Read(buf)
	if err != nil && err != io.EOF {
		return "", err
	}
	if _, err := rs.Seek(0, io.SeekStart); err != nil { // 読んだぶんを巻き戻す
		return "", err
	}
	return http.DetectContentType(buf[:n]), nil
}

Since multipart.File satisfies io.ReadSeeker, you can read the head to judge → Seek(0) to rewind and save.

4.3 Other defenses

RiskCountermeasure
Mixing in an executableMIME allowlist + re-attaching the extension (don't use the client extension)
MalwareAsynchronously scan after upload (S3 malware scanning, etc.) and keep private until quarantine is done
Image bomb (decompression bomb)Validate size/dimensions for images, then re-encode
Excessive uploadRateLimiter on the upload path

5. Presigned URL: don't route huge files through the API

This is the most important production pattern. Uploading a video or a large image via the API server consumes the server's bandwidth, memory, and execution time (and billing). Instead, make the server only issue an "upload permit (presigned URL)," and have the client upload directly to S3.

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

// サーバー:アップロード先と短命の署名付き URL を発行する(ファイル本体は通さない)
func (h *UploadHandler) CreateUploadURL(c *echo.Context) error {
	claims, _ := CurrentUser(c) // 認証必須(誰のアップロードか)
	key := "uploads/" + claims.UserID + "/" + uuid.NewString()

	ps := s3.NewPresignClient(h.s3)
	req, err := ps.PresignPutObject(c.Request().Context(), &s3.PutObjectInput{
		Bucket: &h.bucket,
		Key:    &key,
	}, s3.WithPresignExpires(5*time.Minute)) // 短命:5分で失効
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, map[string]string{"url": req.URL, "key": key})
}

The client uploads directly to S3 with PUT to the returned url. After upload completes, it notifies the server of the key to associate it in the DB.

ApproachVia serverpresigned-URL direct upload
Server bandwidthAll data passes throughDoesn't pass (just the permit)
Memory/execution timeProportional to file sizeConstant (minimal)
Large filesConcern of API timeoutS3 receives directly
CostServer transfer/execution billingGreatly reduced
Suited situationSmall files, immediate processingVideo, large images, bulk upload

Rule of thumb for the judgment: if it's a few MB or less and needs immediate validation/processing, via server; beyond that or bulk, presigned URL. Even with a presigned URL, receive upload completion with an S3 event (S3 → Lambda async processing) to connect to validation, scanning, and thumbnail generation. How to offload heavy post-processing in serverless is the "API thin, heavy processing to event-driven" standard I also adopted on the payment/document platform.


6. Idempotency: don't save the same file twice

To prevent the same file being saved multiple times on retries or double submissions, use the content's hash as the key. Make it "the same content = the same key," and a re-upload just overwrites the same object, producing no duplicates.

// 内容ベースのキー(content-addressed storage)
func contentKey(prefix string, sum [32]byte, ext string) string {
	return prefix + "/" + hex.EncodeToString(sum[:]) + ext // 同一内容 → 同一キー
}

The hash can be computed in parallel with the save stream (io.TeeReader for "hash while saving"). This idea of "guaranteeing idempotency structurally" is the same principle as payment double-charge prevention.


Conclusion: the 7 principles to make file upload production-quality

  1. Stream with io.Copy. Don't load into memory with io.ReadAll.
  2. Reject by size before reading with BodyLimit (the outermost layer of defense in depth).
  3. To S3, connect an io.Reader directly with manager.Uploader. Credentials via an IAM role.
  4. Don't trust file.Filename. The server names with a UUID (path-traversal countermeasure).
  5. Judge MIME by substance with http.DetectContentType and match with an allowlist.
  6. Huge files via a presigned URL, client→S3 direct upload (bandwidth, memory, cost reduction).
  7. Idempotency with a content hash as the key, and offload post-processing to event-driven.

File upload becomes production-quality only when Echo's thin multipart API meshes with the design of storage, security, and cost. If you need real-time progress notifications, WebSocket/SSE; for the big picture, the production-operations guide.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading