Home About Me

Sending Uploads Straight to S3: Presigned PUT vs. POST Policy, with Go Examples

Sending files through an application server before they reach S3 is still common: client → server → S3. It works, but every upload consumes server CPU, memory, and bandwidth; large files also need disk space. When the server does not need to process the file, the browser can send it to S3 directly: client → S3. There are two practical ways to do that with S3, and both can be backed by Go code.

Presigned PUT URLs

The server asks S3 to sign a URL for a specific object key. The URL has an expiry and lets the browser send the file as a binary stream with an HTTP PUT. This pattern fits a single large file particularly well.

The temporary URL is exposed to the browser, so the server still controls the risk:

  • The object key is fixed at signing time, so the client can only write to the intended location.
  • A sensible timeout should be set. After it passes, S3 stops accepting the upload and does not keep the file.
  • Content-Type and Content-Length can be included in the signature. If the uploaded content does not match, S3 returns 403.

The following Go program demonstrates getting an upload presigned URL, uploading via PUT, and generating a download presigned URL after success.

package main

import (
    "bytes"
    "fmt"
    "net/http"
    "time"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/credentials"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
)

// GenerateUploadPresignedURL creates a presigned URL for uploading.
func GenerateUploadPresignedURL(svc *s3.S3, bucketName, objectKey string, expiration time.Duration) (string, error) {
    req, _ := svc.PutObjectRequest(&s3.PutObjectInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(objectKey),
        // ContentType:   aws.String("text/plain"), // "application/json"
        // ContentLength: aws.Int64(19),
    })

    // Generate presigned URL
    signedURL, err := req.Presign(expiration)
    if err != nil {
        return "", fmt.Errorf("error generating upload presigned URL: %w", err)
    }
    return signedURL, nil
}

// UploadContentToS3 uploads content to S3 using the provided presigned URL.
func UploadContentToS3(signedURL, content, contentType string) error {
    req, err := http.NewRequest("PUT", signedURL, bytes.NewBuffer([]byte(content)))
    if err != nil {
        return fmt.Errorf("error creating PUT request: %w", err)
    }
    req.Header.Set("Content-Type", contentType)

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("error executing PUT request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("upload failed with status code %d", resp.StatusCode)
    }

    fmt.Println("Content uploaded successfully")
    return nil
}

// GenerateDownloadPresignedURL creates a presigned URL for downloading.
func GenerateDownloadPresignedURL(svc *s3.S3, bucketName, objectKey string, expiration time.Duration) (string, error) {
    req, _ := svc.GetObjectRequest(&s3.GetObjectInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(objectKey),
    })

    // Generate presigned URL
    signedURL, err := req.Presign(expiration)
    if err != nil {
        return "", fmt.Errorf("error generating download presigned URL: %w", err)
    }
    return signedURL, nil
}

func GetContent() string {
    CNTimeLocation, err := time.LoadLocation("Asia/Shanghai")
    if err != nil {
        panic(err)
    }
    return time.Now().In(CNTimeLocation).Format("2006-01-02 15:04:05")
}

func main() {
    const (
        region     = "cn-northwest-1"
        bucketName = "<your-bucket-name>"
        objectKey  = "<s3-object-target-key>"
        expiration = 15 * time.Minute // URL expiration time
        accessKey  = "<your-access-key>"
        secretKey  = "<your-secret-key>"
    )

    // Initialize session with credentials
    sess, err := session.NewSession(&aws.Config{
        Region: aws.String(region),
        Credentials: credentials.NewStaticCredentials(
            accessKey, secretKey, "",
        ),
    })
    if err != nil {
        fmt.Printf("Error creating AWS session: %v\n", err)
        return
    }

    svc := s3.New(sess)

    // Generate and print upload presigned URL
    uploadURL, err := GenerateUploadPresignedURL(svc, bucketName, objectKey, expiration)
    if err != nil {
        fmt.Printf("Error generating upload presigned URL: %v\n", err)
        return
    }
    fmt.Printf("Upload URL: %s\n", uploadURL)

    // Upload content to S3
    content := GetContent()
    contentType := "text/plain"
    if err := UploadContentToS3(uploadURL, content, contentType); err != nil {
        fmt.Printf("Error uploading content: %v\n", err)
        return
    }

    // Generate and print download presigned URL
    downloadURL, err := GenerateDownloadPresignedURL(svc, bucketName, objectKey, expiration)
    if err != nil {
        fmt.Printf("Error generating download presigned URL: %v\n", err)
        return
    }
    fmt.Printf("Download URL: %s\n", downloadURL)
}

Output:

Presigned URL upload output

A PUT presigned URL is only for uploads; it cannot be used to download or view the object. The separate GET signed URL in the code is what allows viewing or downloading.

Download presigned URL output

A typical browser flow: the user selects a file → the frontend calculates Content-Type, Content-Length, Content-MD5, and related values and submits them to the server → the server returns a presigned URL → the client uploads to S3 → the client notifies the server when the upload succeeds.

POST Policies and Form Fields

This second approach relies on a feature specific to AWS S3. The client gets more flexibility, and the server can grant a broader but still constrained set of permissions.

The server produces form fields such as X-Amz-Date and X-Amz-Signature, along with a policy. The client includes those fields when it POSTs a multipart form to S3.

Compared with a presigned PUT URL, the signature and form fields can be reused during their validity period. That makes the POST form pattern better suited to uploading multiple files.

S3 itself does not support batch uploads. For multiple files, the client submits the form repeatedly, which works well for many small files. The presigned URL approach is clumsier in that scenario: every file needs a fresh request to the server, and the client cannot choose its own key path. The POST form is flexible, but because it uses AWS-specific policy behavior, it is not portable to S3-compatible storage services.

The server can constrain uploads with a policy. For example, it can require files to be between 1 and 50 MB and restrict Content-Type to image/*, while also setting a reasonable expiry.

The policy below describes the conditions an upload must satisfy for S3's presigned POST protocol:

{ "expiration": "2015-12-30T12:00:00.000Z",
  "conditions": [
    {"bucket": "sigv4examplebucket"},
    ["starts-with", "$key", "user/user1/"],
    {"acl": "public-read"},
    {"success_action_redirect": "http://sigv4examplebucket.s3.amazonaws.com/successful_upload.html"},
    ["starts-with", "$Content-Type", "image/"],
    {"x-amz-meta-uuid": "14365123651274"},
    {"x-amz-server-side-encryption": "AES256"},
    ["starts-with", "$x-amz-meta-tag", ""],

    {"x-amz-credential": "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"},
    {"x-amz-algorithm": "AWS4-HMAC-SHA256"},
    {"x-amz-date": "20151229T000000Z" }
  ]
}

Reference: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

The Go implementation uses aws-sdk-go-v2. The form fields determine where the file goes and what is uploaded.

package main

import (
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "log"
    "path/filepath"
    "time"

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

// AWS configuration constants
const (
    Region     = "cn-northwest-1"
    BucketName = "<your-bucket-name>"
    AccessKey  = "<your-access-key>"
    SecretKey  = "<your-secret-key>"
)

func main() {
    // Load AWS configuration
    cfg, err := loadAWSConfig(context.TODO())
    if err != nil {
        log.Fatalf("unable to load SDK config: %v", err)
    }

    // Define the object key
    keyPrefix := "s3-web-upload"

    // Set time values
    timeStamp := time.Now().UTC()
    shortDate, amzDate := formatDates(timeStamp)

    // Create the AWS credential string
    credential := fmt.Sprintf("%s/%s/%s/s3/aws4_request", AccessKey, shortDate, Region)

    // Create the policy
    expiration := timeStamp.Add(15 * time.Minute)
    policy := createPolicy(BucketName, keyPrefix, credential, amzDate, expiration)

    // Retrieve signing credentials
    creds, err := cfg.Credentials.Retrieve(context.TODO())
    if err != nil {
        log.Fatalf("unable to retrieve credentials: %v", err)
    }

    // Sign the policy
    signature, err := generateSignature(policy, creds, Region, shortDate)
    if err != nil {
        log.Fatalf("unable to sign policy: %v", err)
    }

    // Print the necessary fields for POST request
    printPostFields(BucketName, policy, creds, signature, credential, amzDate)

    // Generate and print the CURL command, upload txt
    objectKey := "s3-web-upload/test03-post-form/hello.txt"
    curlCommand := generateCURLCommand(BucketName, objectKey, "text/plain", policy, credential, amzDate, signature)
    fmt.Println("\nCURL Command:")
    fmt.Println(curlCommand)

    // Generate and print the CURL command, upload jpg
    objectKey = "s3-web-upload/test03-post-form/hello.jpg"
    curlCommand = generateCURLCommand(BucketName, objectKey, "image/jpeg", policy, credential, amzDate, signature)
    fmt.Println("\nCURL Command:")
    fmt.Println(curlCommand)
}

// loadAWSConfig loads AWS configuration using provided credentials and region
func loadAWSConfig(ctx context.Context) (aws.Config, error) {
    return config.LoadDefaultConfig(ctx,
        config.WithRegion(Region),
        config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(AccessKey, SecretKey, "")),
    )
}

// formatDates generates formatted date strings for use in AWS policies and signatures
func formatDates(timeStamp time.Time) (shortDate, amzDate string) {
    shortDate = timeStamp.Format("20060102")
    amzDate = timeStamp.Format("20060102T150405Z")
    return
}

type s3PostPolicy struct {
    Expiration string        `json:"expiration"`
    Conditions []interface{} `json:"conditions"`
}

// createPolicy generates a base64 encoded JSON policy document with given conditions
func createPolicy(bucket, keyPrefix, credential, amzDate string, expiration time.Time) string {
    conditions := []interface{}{
        map[string]string{"bucket": bucket},
        map[string]string{"x-amz-algorithm": "AWS4-HMAC-SHA256"},
        map[string]string{"x-amz-credential": credential},
        map[string]string{"x-amz-date": amzDate},
        []interface{}{"content-length-range", 0, 10485760}, // 10MB
        []interface{}{"starts-with", "$key", keyPrefix},
        []interface{}{"starts-with", "$Content-Type", ""}, // e.g. image/
    }

    policy := s3PostPolicy{
        Expiration: expiration.UTC().Format(time.RFC3339),
        Conditions: conditions,
    }

    policyBytes, _ := json.Marshal(policy)
    return base64.StdEncoding.EncodeToString(policyBytes)
}

// generateSignature creates an AWS v4 signature for the provided policy
func generateSignature(policy string, creds aws.Credentials, region, date string) (string, error) {
    signingKey := deriveSigningKey(creds.SecretAccessKey, date, region, "s3")
    signature := hmacSHA256(signingKey, policy)
    return hex.EncodeToString(signature), nil
}

// deriveSigningKey derives the signing key used for AWS signature v4
func deriveSigningKey(secret, date, region, service string) []byte {
    kDate := hmacSHA256([]byte("AWS4"+secret), date)
    kRegion := hmacSHA256(kDate, region)
    kService := hmacSHA256(kRegion, service)
    return hmacSHA256(kService, "aws4_request")
}

// hmacSHA256 performs HMAC-SHA256 hashing algorithm with given key and data
func hmacSHA256(key []byte, data string) []byte {
    h := hmac.New(sha256.New, key)
    h.Write([]byte(data))
    return h.Sum(nil)
}

// printPostFields displays the constructed fields needed for S3 POST requests
func printPostFields(bucketName, policy string, creds aws.Credentials, signature, credential, amzDate string) {
    fmt.Printf("URL: https://%s.s3.%s.amazonaws.com.cn\n", bucketName, Region)
    fmt.Println("Fields:")
    //fmt.Printf("Key: %s\n", key)
    fmt.Printf("AWSAccessKeyId: %s\n", creds.AccessKeyID)
    fmt.Printf("Policy: %s\n", policy)
    fmt.Printf("x-amz-signature: %s\n", signature)
    fmt.Printf("x-amz-credential: %s\n", credential)
    fmt.Printf("x-amz-algorithm: AWS4-HMAC-SHA256\n")
    fmt.Printf("x-amz-date: %s\n", amzDate)
}

// generateCURLCommand generates a CURL command for uploading a file to S3
func generateCURLCommand(bucketName, key, contentType, policy, credential, amzDate, signature string) string {
    filename := filepath.Base(key)
    curlTemplate := `curl -X POST \
  -F "key=%s" \
  -F "Content-Type=%s" \
  -F "X-Amz-Credential=%s" \
  -F "X-Amz-Algorithm=AWS4-HMAC-SHA256" \
  -F "X-Amz-Date=%s" \
  -F "Policy=%s" \
  -F "X-Amz-Signature=%s" \
  -F "file=@%s" \
  https://%s.s3.%s.amazonaws.com.cn/`

    return fmt.Sprintf(curlTemplate, key, contentType, credential, amzDate, policy, signature, filename, bucketName, Region)
}

Output:

POST policy form output

Running the two generated curl commands uploads hello.txt and hello.jpg from the current directory (prepare the files first). If S3 returns no error, the upload succeeded; otherwise S3 reports the reason.

A typical browser flow: the user selects files → the client gets the URL and form fields from the server → the client uploads to S3 (looping for multiple files) → the client sends upload success information back to the server.

For further reading, see AWS's examples for browser-based uploads with HTTP POST and Signature Version 4.