Skip to content
gohacks.dev
APIs

File Uploads in a Go API Using Presigned Storage URLs

Let clients upload straight to object storage with presigned URLs. Your Go API checks type and size, signs a short-lived PUT, and never carries the bytes.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Accepting uploads through your API server works until it does not. A 200 MB video ties up a connection, a request body buffer and a worker for as long as the client's slow mobile uplink takes to send it. Multiply by a few dozen concurrent users and the API that serves your JSON is busy copying bytes to disk. The standard fix is to take the API out of the data path: the client uploads directly to object storage, and the API's only job is deciding who may do that and how.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The mechanism is a presigned URL. This article builds the whole flow in Go with the standard library, using an HMAC signature in place of a cloud provider so you can run it without an account, and then shows how the same shape maps onto S3, Google Cloud Storage and compatible services. It connects to the REST API design article for the request/response shapes, and the security checklist for what else belongs around an upload. Everything ran on Go 1.25.6.

The three steps

  1. The client asks the API for permission: POST /v1/uploads with a filename, content type and size.
  2. The API checks that this user may upload that kind of file and returns a URL, signed for a few minutes.
  3. The client sends PUT to that URL. Storage verifies the signature and stores the bytes.

Usually there is a fourth step where the client tells the API the upload finished, so the API can record the file against a post or a profile. Never trust the client's claim blindly there: look the object up in storage and read its size and type from the source.

What the signature has to cover

A presigned URL is a capability. Whoever holds it can do exactly what the signature allows and nothing else, so the work is deciding what goes into the signed string. In the lab it is the HTTP method, the key, the content type, the maximum size and the expiry time. Anything not in the signature can be changed by the client without invalidating it.

main.goGo
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"
)

var secret = []byte("change-me-signing-key")

// sign covers everything the server is willing to accept: key, content type, size cap and expiry.
func sign(key, ctype string, max int64, exp int64) string {
	m := hmac.New(sha256.New, secret)
	fmt.Fprintf(m, "PUT\n%s\n%s\n%d\n%d", key, ctype, max, exp)
	return hex.EncodeToString(m.Sum(nil))
}

type blobStore struct {
	mu   sync.Mutex
	data map[string][]byte
}

func main() {
	store := &blobStore{data: map[string][]byte{}}
	now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	mux := http.NewServeMux()

	// Step 1: the API decides whether this caller may upload, then hands out a short-lived URL.
	mux.HandleFunc("POST /v1/uploads", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Filename string `json:"filename"`
			Type     string `json:"content_type"`
			Size     int64  `json:"size"`
		}
		json.NewDecoder(r.Body).Decode(&in)
		if in.Type != "image/png" && in.Type != "image/jpeg" {
			http.Error(w, `{"error":"only png and jpeg"}`, http.StatusUnprocessableEntity)
			return
		}
		if in.Size <= 0 || in.Size > 1024 {
			http.Error(w, `{"error":"size must be 1..1024 bytes"}`, http.StatusUnprocessableEntity)
			return
		}
		key := "uploads/u42/" + in.Filename
		exp := now.Add(5 * time.Minute).Unix()
		q := url.Values{"type": {in.Type}, "max": {strconv.FormatInt(in.Size, 10)}, "exp": {strconv.FormatInt(exp, 10)},
			"sig": {sign(key, in.Type, in.Size, exp)}}
		json.NewEncoder(w).Encode(map[string]any{"upload_url": "/storage/" + key + "?" + q.Encode(), "method": "PUT", "key": key})
	})

	// Step 2: the storage service. It never sees a session or a user, only a signature.
	mux.HandleFunc("PUT /storage/{key...}", func(w http.ResponseWriter, r *http.Request) {
		key, q := r.PathValue("key"), r.URL.Query()
		max, _ := strconv.ParseInt(q.Get("max"), 10, 64)
		exp, _ := strconv.ParseInt(q.Get("exp"), 10, 64)
		want := sign(key, q.Get("type"), max, exp)
		switch {
		case !hmac.Equal([]byte(want), []byte(q.Get("sig"))):
			http.Error(w, "signature mismatch", http.StatusForbidden)
		case now.Unix() > exp:
			http.Error(w, "url expired", http.StatusForbidden)
		case r.Header.Get("Content-Type") != q.Get("type"):
			http.Error(w, "content type does not match the signed one", http.StatusForbidden)
		default:
			body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, max))
			if err != nil { // MaxBytesReader fails the read; checking len(body) alone would store a silent truncation
				http.Error(w, "too large", http.StatusRequestEntityTooLarge)
				return
			}
			store.mu.Lock()
			store.data[key] = body
			store.mu.Unlock()
			w.WriteHeader(http.StatusOK)
		}
	})

	srv := httptest.NewServer(mux)
	defer srv.Close()

	ask := func(body string) (string, int) {
		resp, _ := http.Post(srv.URL+"/v1/uploads", "application/json", strings.NewReader(body))
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		var out struct {
			URL string `json:"upload_url"`
		}
		json.Unmarshal(b, &out)
		return out.URL, resp.StatusCode
	}
	put := func(label, path, ctype string, payload []byte) {
		req, _ := http.NewRequest(http.MethodPut, srv.URL+path, strings.NewReader(string(payload)))
		req.Header.Set("Content-Type", ctype)
		resp, _ := http.DefaultClient.Do(req)
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%-28s %d %s\n", label, resp.StatusCode, strings.TrimSpace(string(b)))
	}

	_, code := ask(`{"filename":"x.exe","content_type":"application/x-msdownload","size":10}`)
	fmt.Println("ask for an .exe:             ", code)
	_, code = ask(`{"filename":"big.png","content_type":"image/png","size":999999}`)
	fmt.Println("ask for 999999 bytes:        ", code)

	path, code := ask(`{"filename":"cat.png","content_type":"image/png","size":16}`)
	fmt.Println("ask for 16 byte png:         ", code)
	put("upload exactly as signed", path, "image/png", []byte("0123456789abcdef"))
	put("same URL, bigger body", path, "image/png", []byte("0123456789abcdef-and-more"))
	put("same URL, wrong type", path, "text/html", []byte("<script>"))
	put("tampered key", strings.Replace(path, "cat.png", "admin.png", 1), "image/png", []byte("0123456789abcdef"))
	put("tampered size limit", strings.Replace(path, "max=16", "max=9999", 1), "image/png", []byte("0123456789abcdef"))

	now = now.Add(6 * time.Minute)
	put("six minutes later", path, "image/png", []byte("0123456789abcdef"))
	fmt.Println("objects stored:", len(store.data))
}
Text
ask for an .exe:              422
ask for 999999 bytes:         422
ask for 16 byte png:          200
upload exactly as signed     200 
same URL, bigger body        413 too large
same URL, wrong type         403 content type does not match the signed one
tampered key                 403 signature mismatch
tampered size limit          403 signature mismatch
six minutes later            403 url expired
objects stored: 1

The signed key is chosen by the server (uploads/u42/ plus the filename). That is the first rule: never let the client pick a full path. In production, generate the object name yourself, a random id plus an extension taken from the validated content type, and keep the client's filename as metadata. A filename like ../../config or one that overwrites another user's file is not a problem you want to reason about.

What the run shows

The two refusals at the top happen in step one, before a signature exists: an .exe is not an allowed type and 999999 bytes is over the limit, both 422. That matters because the policy is in your code, in one place, and storage is not asked to make business decisions.

The upload exactly as signed returns 200. Then each attempt to bend the rules fails differently:

  • A body larger than the signed limit gets 413.
  • A different Content-Type header gets 403, because the signature includes the type the API approved and the storage handler compares it with the request.
  • A changed key and a changed max both get 403 signature mismatch. Both are inside the signed string, so the HMAC no longer matches.
  • After the clock moves six minutes, the original, perfectly valid URL answers url expired.

Out of eight upload attempts exactly one object was stored.

A bug the lab caught

The first version of the storage handler looked like this: read the body through http.MaxBytesReader, then compare len(body) to the limit. It stored the oversized upload as a silently truncated 16 byte file and answered 200.

io.ReadAll stops when MaxBytesReader returns its error, and the body slice contains the bytes read up to the cap, which is never greater than the cap. The length check cannot fail. The fix is to look at the error from ReadAll, which the final lab does. If you take one habit from this article, make it that: a limit enforced by a reader has to be checked through the reader's error, not through the size of what it handed back. The JSON size limits article covers the same trap for request bodies.

Mapping this onto real storage

You rarely write the storage side. The shape is the same with a provider SDK, and the API only changes in step two.

With AWS S3 the AWS SDK for Go v2 has a PresignClient. Its PresignPutObject takes the bucket, key, content type and a content length, and returns a URL plus the headers the client must send. Signed headers are part of the signature, which is how the type and length are pinned. Google Cloud Storage has signed URLs through storage.SignedURL with the same idea, and many S3-compatible services such as MinIO and Cloudflare R2 accept S3-style presigned URLs. The S3 presigned URL documentation lists exactly what is signed.

One difference to know about: a presigned PUT can pin Content-Length exactly, but it cannot express "any size up to 5 MB". If you need a range, S3 offers presigned POST with a policy document containing a content-length-range condition. For exact sizes, which is what a client reports in step one, PUT is simpler.

I am not running the AWS SDK in this article. The mechanism is an HMAC over a canonical request either way, and the lab keeps it small enough to read.

Choices that decide whether this is safe

Keep expiry short. Five minutes is enough for a client that starts immediately. A URL that lives for a day is a day long window to leak. If a large file needs more time, the expiry only has to cover the start of the request in most providers, not the whole transfer, but check the documentation for yours.

Use a bucket that cannot be listed or read publicly. Presigned uploads land in a private bucket. Reads then also go through presigned URLs, or through a CDN that you control. The authentication article covers how to decide who may ask for either.

Do not trust the content type. The client says image/png; the bytes may be an HTML page. If anything renders the file back to a browser, serve it with a fixed Content-Type and Content-Disposition, and consider a scan or a re-encode step after upload. Image re-encoding is a good default for avatars because it strips metadata and active content.

Bound what a user can do. Rate limit step one, and cap the total storage per account. Without those, a signed upload is a cheap way for one user to fill your bucket. See API rate limiting.

Clean up abandoned uploads. Clients ask for URLs and never use them. Use a lifecycle rule on the bucket to delete objects under an incoming/ prefix that were never confirmed, and move confirmed ones to their final key.

Checking the upload afterwards

When the client reports completion, the API should read the object's metadata from storage rather than the request: size, content type, and an ETag or checksum. If the client also sent a SHA-256 in step one, compare it. Only then create the database row, and keep a status such as pending and ready so a half-confirmed file is not visible to other users.

For multi-gigabyte files, look at multipart uploads. The API presigns each part, the client uploads them in parallel and can resume, and one final call completes the object. It is more calls and more state, and it is the right answer once a single PUT is too fragile to retry.

When this is the wrong design

Small files, such as a 20 KB avatar or a CSV the server has to parse immediately, are often simpler through the API. The extra round trip and the confirmation step are overhead you do not need, and the handler can enforce MaxBytesReader itself. Switch to presigned URLs when files are big enough that holding the connection is the problem, or when you want upload traffic off your servers and bill. Pair the choice with the limits in Go's net/http documentation for MaxBytesReader whenever the bytes do pass through your handler.

Also inDevOps
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.