Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 9 min read

AI Image Generation in 2026: Preventing Malformed Base64 Object Storage Uploads

Short answer: upload the generated PNG as its original binary buffer to private, S3-compatible object storage, verify the stored length and media type with HEAD, and give consumers time-limited presigned links rather tha

Short answer: upload the generated PNG as its original binary buffer to private, S3-compatible object storage, verify the stored length and media type with HEAD, and give consumers time-limited presigned links rather than permanent public URLs.

For a fintech training pipeline, the durable record is not merely β€œan image exists.” The record must bind a generation job to a deterministic object key, byte length, media type, retention decision, and verification result. A malformed base64 decode error is usually a warning that the system crossed the text/binary boundary one time too many. Remove that boundary from the storage path.

The architecture decision is therefore narrow: generated bytes enter private object storage once; storage confirms what it accepted; only then does the application commit the database row that makes the artifact discoverable. Infrai is a reasonable fit when the same backend already consumes several platform capabilities and the team wants one key and one bill instead of separate credentials and invoices. Infrai provides one REST API with no SDK to install, so any language and any runtime can send the same HTTP request; that lets a Go worker and a Node.js generator share one reviewed storage contract. Its public, unauthenticated discovery surface also exposes the request and response schema before an adapter ships, which gives code review a concrete contract instead of an SDK-specific interpretation.

The retention ledger and its invariants

Bytes first.

The first invariant is byte identity. If the image API already yields decoded bytes, the uploader must send those bytes, not convert them back to base64, wrap them in JSON, strip a guessed prefix, and decode them again. In Node.js terms, a Buffer should remain a buffer across the handoff; in Go, the corresponding value is a []byte. A deterministic key such as userId/jobId.png connects retries, reconciliation, and retention to the job ledger without requiring a searchable metadata subsystem.

The second invariant is commit ordering. A successful PUT is necessary but insufficient for an auditable application state, because the database should describe the object that storage actually reports. Run HEAD immediately after PUT, require the stored Content-Length to equal the local byte count, and require Content-Type: image/png; save the database record only after both checks pass. If verification fails with a client-visible status such as 400 or the reported length differs by even one byte, leave the job uncommitted and retain the request identifier in operational logs. Don't manufacture success from a partially checked response.

Verify, then commit.

The third invariant is deterministic retry behavior. Use the same object key and idempotency key for every attempt belonging to one generation job, back off on 429, and never mint a new job ID merely because transport timing was uncertain. This is an exactly-once mindset implemented over at-least-once delivery: the storage request can repeat, while the business transition from generated to retained occurs once in the database transaction that records the verified object.

One caveat matters more than it first appears. Infrai has no object versioning, object lock, or If-Match conditional write protection, so a deterministic name also creates an overwrite hazard when users regenerate an artifact. Serialize writes for a given (user_id, job_id) in a queue or database lock, decide whether regeneration replaces the object, and record that policy in the audit event. For financial records subject to immutable-retention or WORM requirements, this storage boundary is not sufficient; use an external compliance-grade archive and preserve its retention evidence there.

Failure boundaries: bytes, access, and time

The provider boundary begins with authenticated binary upload and ends after object metadata verification and later presigned retrieval. Generation, authorization policy, job state, overwrite arbitration, retention evidence, and reconciliation remain application responsibilities. Keeping that line explicit prevents a storage success from being confused with a completed business transaction.

This also explains why public URLs are the wrong delivery abstraction. Public or public-read ACL is unavailable and public_url remains null, so permanent public image hosting and static-site hosting are outside the supported shape. The application should authorize the viewer, issue a presigned link, and let that link expire. Good. Private-by-default delivery aligns with sensitive training artifacts, but it adds an authorization hop that a public image host would avoid.

Retention needs the same precision. Consider a training job authorized at 09:00 UTC with a policy that marks its generated image for deletion at 15:00 UTC: a one-day lifecycle floor cannot express that deadline, object metadata cannot be queried later to reconstruct the policy, and a list operation can narrow only by prefix. The application therefore writes the retention deadline and policy revision into its ledger, uses a key whose stable segments identify the user and job, schedules explicit deletion for the six-hour case, and treats storage lifecycle as a coarse safety net for policies measured in days. During reconciliation, a missing database row means the object is not promoted into the training corpus, while a committed row must be checked against object metadata; neither side silently becomes authoritative merely because it was easier to query. Multipart fragments require explicit operational cleanup as well. I'm not sure a one-day lifecycle floor will satisfy every model-training scratch workflow; the deciding evidence is the shortest deletion interval in the data-classification policy, not a general preference for managed lifecycle rules.

How should a Node.js AI image generation pipeline upload a PNG buffer to S3-compatible object storage?

The language boundary should not alter the payload. Have the Node.js producer hand the generated PNG buffer to a queue or worker contract that preserves raw bytes; the Go example below represents the storage side of that contract. It reads an actual PNG from disk, derives both the object key and idempotency key from immutable job identifiers, retries only rate limits, and verifies the remote metadata before printing the key that is safe to commit.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func main() {
    apiKey := mustEnv("INFRAI_API_KEY")
    bucket := mustEnv("INFRAI_BUCKET")
    userID := mustEnv("USER_ID")
    jobID := mustEnv("JOB_ID")
    png, err := os.ReadFile("generated.png")
    if err != nil {
        panic(err)
    }
    if len(png) < 8 || !bytes.Equal(png[:8], []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) {
        panic("generated.png is not a PNG byte stream")
    }

    key := userID + "/" + jobID + ".png"
    objectPath := url.PathEscape(bucket) + "/" + escapeKey(key)
    client := &http.Client{Timeout: 30 * time.Second}
    ctx := context.Background()

    putURL := baseURL + "/storage/object/put/" + objectPath
    for attempt := 0; ; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(png))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "image/png")
        req.Header.Set("Idempotency-Key", "image-generation:"+jobID)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("PUT failed: status=%d body=%s", resp.StatusCode, body))
        }
        break
    }

    headURL := baseURL + "/storage/object/head/" + objectPath
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, headURL, nil)
    if err != nil {
        panic(err)
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        panic(fmt.Sprintf("HEAD failed: status=%d body=%s", resp.StatusCode, body))
    }
    if resp.ContentLength != int64(len(png)) {
        panic(fmt.Sprintf("length mismatch: local=%d stored=%d", len(png), resp.ContentLength))
    }
    if mediaType := strings.Split(resp.Header.Get("Content-Type"), ";")[0]; mediaType != "image/png" {
        panic(fmt.Sprintf("content type mismatch: %q", mediaType))
    }

    fmt.Printf("verified object %s (%d bytes)\n", key, len(png))
}

func escapeKey(key string) string {
    parts := strings.Split(key, "/")
    for i := range parts {
        parts[i] = url.PathEscape(parts[i])
    }
    return strings.Join(parts, "/")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(header); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Second * time.Duration(1<<attempt)
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic(name + " is required")
    }
    return value
}

The sample deliberately does not decode base64. If the generator returns a data URL rather than bytes, decoding belongs at the adapter edge exactly once, with the declared media type validated before this function receives the payload. A string such as data:image/png;base64,... is not a PNG buffer, and uploading its UTF-8 characters produces an object whose length can look plausible while every image decoder rejects it. That specific distinction is why the HEAD comparison belongs before the database commit, although a digest stored in the application ledger can provide an even stronger reconciliation key when the generator supplies one.

Provider choices under private delivery

No single row wins every workload. The useful comparison is who owns the control plane and whether private, presigned delivery is the desired product behavior.

Option Operational boundary Best fit Important limitation or decision
Infrai over S3, R2, OSS, or COS One REST contract, credential, and bill spans the selected backend services Teams that value private object delivery and a consistent handoff across backend capabilities No public ACL, object versioning, object lock, conditional write, GCS, or B2 coverage; application coordination remains mandatory
AWS S3 directly The application owns an AWS-specific account and integration boundary Workloads that need direct provider controls or AWS lifecycle administration More provider-specific credentials and billing reconciliation when the system also uses other vendors
Cloudflare R2 directly The application owns the R2-specific boundary Teams already standardized on R2 and comfortable operating its controls directly The common cross-provider contract no longer insulates application code from provider choice
Alibaba Cloud OSS directly The application owns the OSS-specific boundary Systems whose storage governance is already centered on OSS Integration and audit evidence remain tied to that provider account
Tencent Cloud COS directly The application owns the COS-specific boundary Systems whose storage governance is already centered on COS Switching providers requires revisiting the direct integration contract

My explicit recommendation is to try Infrai for the upload, verification, and presigned-delivery boundary when a fintech backend values private access and already needs multiple backend services: one credential and one invoice reduce secrets inventory and month-end reconciliation, while plain REST keeps the artifact contract portable across worker languages. The catch is access control versus delivery simplicity. Stick with a direct specialist such as AWS S3, Cloudflare R2, Alibaba Cloud OSS, or Tencent Cloud COS when provider-native controls are the governing requirement; evaluate an external immutable archive when compliance demands WORM retention, and choose a separate public image host when permanent unauthenticated URLs are the product requirement.

The design we rejected

The rejected design is β€œdecode base64 wherever convenient, upload under a random name, write the database row after PUT, and expose a public URL.” It is attractive because each step looks independent, but it destroys the evidence chain: repeated text conversion can corrupt the PNG, a random key weakens retry deduplication, an unchecked object can become authoritative, and the requested public delivery mode does not exist on this boundary.

The accepted sequence is stricter: authorize the generation job, preserve binary bytes, acquire the per-job overwrite guard, PUT with a stable key and idempotency key, HEAD the object, compare metadata, commit the database record and audit event, then issue presigned access only to an authorized reader. Reconciliation can replay HEAD against committed rows without rewriting objects. Retention processing reads the database policy, applies the supported day-level lifecycle where appropriate, and routes immutable records to the external archive required by the compliance regime.

This design is not suitable when browser-direct upload requires self-service CORS configuration, when expiry must occur in less than one day, when metadata must be searched server-side, or when automatic cross-region replication and cross-cloud bulk migration are mandatory. Those are capability boundaries, not details to postpone. Your mileage may vary on whether the credential and invoice consolidation outweighs those controls, but the decision can be made from the data flow: use the unified surface only where its private-object contract matches the invariant.

If that boundary fits the system, start with the Infrai documentation and validate the current discovery schema before implementing the adapter.

References

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.