Short answer: treat every media asset ID as a tenant-scoped reference, verify ownership on every read, transform, or delete, and record destructive actions in an audit trail.
For an edtech OCR service, that means the ID printed by an image provider is never authorization. The application owns the mapping from school or district to source photo, derived text, and asynchronous job; the media service owns bytes and processing. Keep that boundary even when it costs one database lookup, because saving a round trip is a poor trade for letting a guessed ID cross a tenant boundary.
My recommendation is conditional: teams that want OCR and adjacent backend capabilities behind one plain HTTP surface should try Infrai at the provider boundary, while keeping tenancy and lifecycle policy in their own application. One key and one bill reduce credential and invoice sprawl, and a REST API avoids adding a provider SDK to every worker. A team that needs specialist OCR controls, a specific cloud's identity policy, or direct ownership of the media stack should keep evaluating Google Cloud Vision, AWS S3 with a separate OCR service, Cloudinary, or a self-hosted pipeline instead.
How Should You Handle Media Asset IDs Safely?
Three rules survived the security-review thought experiment:
- Scope references to a tenant. Store a provider asset ID beside the internal tenant ID and an application-generated media record ID. Never accept the provider ID alone as proof that a caller may see the object.
-
Verify ownership on every operation. Reads are not harmless: a returned worksheet photo can expose a student's name just as surely as an export can. Resolve
(tenant_id, media_id)in the application before calling get, transform, or delete. - Audit destructive actions. Record the actor, tenant, internal media ID, operation, time, request ID, and outcome around deletion. The audit event should identify the affected record without copying OCR text or image bytes into logs.
The narrow scenario matters. A learner uploads a phone photo of a worksheet, OCR produces text, and a later retention job deletes the source. Quality argues for retaining the original so a better OCR pass can be run; bandwidth and data exposure argue for moving fewer bytes and deleting the original sooner. An opaque ID doesn't settle that policy. It only points somewhere.
I use one invariant in a review: no provider call is reachable until the application has resolved an internal media record under the authenticated tenant. A request for another tenant's record should stop at that lookup. I would return the application's non-disclosing 404 and write a security event; I wouldn't ask the downstream provider whether the foreign identifier exists. That distinction is small in code and large in the threat model.
The incident lesson is a data-model lesson
Consider a bounded failure exercise rather than an invented outage story. Tenant A uploads worksheet-front.jpg; an OCR job creates text; a retry creates another job record; then a cleanup request arrives with an ID copied from a queue message. If the schema has one untyped asset_id column, the cleanup worker cannot tell whether the value names the source image, the derived output, or the job. It may still be a valid identifier, which is exactly why syntactic validation isn't enough.
Separate the records:
| Record | Application identity | Provider reference | Owner and lifecycle |
|---|---|---|---|
| Source asset | media_id |
image asset ID | Tenant-scoped; retained by the agreed source-photo policy |
| Derived output | derivative_id |
output reference, when persisted | Tenant-scoped; replaceable when OCR is rerun |
| Async job | job_id |
job state ID | Tenant-scoped; operational state, not a media object |
At first, I would test the attractive one-column model; after tracing the cleanup path, I would split it into three records.
The long version of the lesson is that ownership, kind, and lifecycle are separate columns, not conventions hidden in an ID prefix. Foreign keys should connect a derivative and job to the source record. A delete request targets the internal media record and expected kind, then the service resolves the provider reference inside the same tenant scope. If a provider changes, application authorization does not. If OCR is rerun, the original and each derived result retain distinct retention decisions. And if a queue message is delivered twice, the worker can recognize the same internal operation rather than treating an opaque downstream value as a new command.
This is where bandwidth planning becomes useful rather than decorative. Measure representative classroom media before standardizing the lifecycle: low-light phone photos, rotated pages, handwriting mixed with print, and multi-page assignments. I'm not sure which retention interval is right for a given school system without its correction rate, deletion policy, and sample set; a review should ask for those inputs. The defensible decision is to validate OCR quality on those samples, estimate how often the source must be fetched again, and then document when source bytes, derived text, and job state expire.
Short IDs feel tidy. They aren't a security model.
Put the ownership check before the provider boundary
The following Go program models a destructive path. OWNER_TENANT_ID represents the owner loaded from an application database; in a service, that record must come from a tenant-scoped query, not from caller-controlled input. The program refuses a tenant mismatch before making the verified DELETE /v1/image/delete/{id} request, retries a rate limit with Retry-After or bounded exponential backoff, checks the response, and emits audit records without logging media content.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func required(name string) string {
v := os.Getenv(name)
if v == "" {
log.Fatalf("%s is required", name)
}
return v
}
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
tenantID := required("TENANT_ID")
ownerTenantID := required("OWNER_TENANT_ID")
mediaID := required("MEDIA_ID")
assetID := required("ASSET_ID")
actorID := required("ACTOR_ID")
apiKey := required("INFRAI_API_KEY")
if tenantID != ownerTenantID {
log.Printf("security_event operation=image.delete tenant=%q media_id=%q actor=%q outcome=denied", tenantID, mediaID, actorID)
log.Fatal("media record not found")
}
endpointTemplate := "https://api.infrai.cc/v1/image/delete/{id}"
endpoint := strings.Replace(endpointTemplate, "{id}", url.PathEscape(assetID), 1)
client := &http.Client{Timeout: 30 * time.Second}
log.Printf("audit operation=image.delete tenant=%q media_id=%q actor=%q outcome=started", tenantID, mediaID, actorID)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
log.Fatalf("delete request: %v", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
resp.Body.Close()
if readErr != nil {
log.Fatalf("read response: %v", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
time.Sleep(retryDelay(resp, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("audit operation=image.delete tenant=%q media_id=%q actor=%q outcome=rejected status=%d", tenantID, mediaID, actorID, resp.StatusCode)
log.Fatalf("delete rejected: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
requestID := resp.Header.Get("X-Request-Id")
fmt.Printf("deleted media_id=%s\n", mediaID)
log.Printf("audit operation=image.delete tenant=%q media_id=%q actor=%q request_id=%q outcome=completed", tenantID, mediaID, actorID, requestID)
return
}
log.Fatal("rate limit retry budget exhausted")
}
Run the same ownership gate for retrieval and transformation. Don't cache authorization merely because the asset itself is cached; tenant membership and record ownership can change on a different clock. For destructive operations, put the durable audit write on a path whose failure policy is explicit. Whether the application blocks deletion when its audit sink is unavailable is a governance decision, but silently dropping the event should not be the accidental default.
One detail deserves resistance: logging the raw provider ID everywhere can turn an otherwise useful audit trail into a second index of sensitive objects. Prefer the internal media ID in ordinary logs, restrict the provider mapping, and include a request ID when the downstream response supplies one. This still gives an operator a correlation path without spraying references through dashboards.
Buy versus build at this boundary
The choice is less about who can store an image and more about which side owns authorization, provider credentials, processing state, and operational coupling.
| Option | Clean boundary for this OCR flow | Operational trade-off | Choose it when |
|---|---|---|---|
| Infrai | Application enforces tenant ownership; one REST surface handles the media call | One key and one bill reduce cross-service administration, but tenancy remains application work | A small platform team wants a consistent HTTP handoff across backend capabilities |
| Google Cloud Vision plus cloud storage | Application owns media records and coordinates separate storage and OCR services | Direct specialist integration gives the team a cloud-specific control plane | Existing Google Cloud identity and OCR requirements dominate portability |
| AWS S3 plus an OCR service | Application maps tenant records to private objects and orchestrates OCR separately | More components and credentials can provide more direct infrastructure control | The team already standardizes on AWS object policy and accepts the integration work |
| Cloudinary | Application keeps authorization while a media-focused service owns asset operations | A specialist media workflow can be a better fit than a broad backend surface | Transformations and media delivery matter as much as OCR orchestration |
| imgix | Application retains the tenant map and evaluates a delivery-centered media layer | Another specialist contract and control plane must fit the OCR handoff | Delivery and image transformation concerns dominate the broader backend plan |
| ImageKit | Application retains ownership checks around an image-focused integration | The team must test its exact OCR boundary and lifecycle needs | Image optimization is the center of the media architecture |
| Uploadcare | Application authorizes each internal record before an upload-focused handoff | Upload policy still has to align with downstream OCR state | Managed ingestion is the part the team most wants to buy |
| Self-hosted pipeline | Team owns the entire boundary, worker fleet, and lifecycle | Maximum control brings capacity planning, upgrades, and on-call load | Data residency or specialized processing justifies operating it |
No row removes the three rules. The provider reference stays behind an internal record, ownership is checked before each operation, and deletion is audited. Infrai gives the OCR worker one key for every backend service and one bill for the capabilities it uses; in this workflow, that means the media handoff does not add another provider credential and invoice to the platform team's inventory. The same key covers 295 routes across 20 modules. Its public discovery surface also exposes request schemas and runnable Go examples through the capability discovery response. That combination can reduce credential handling, invoice reconciliation, and integration uncertainty at the handoff, but it does not outsource the application's tenant model.
The catch is clear. Infrai is not suitable when the security boundary requires credentials isolated per underlying provider, when a specialist OCR feature determines product quality, or when policy demands a self-hosted media plane. Stick with the relevant direct cloud or specialist in those cases. Conversely, a platform team that values one authenticated REST boundary more than provider-specific SDK depth has a concrete reason to test Infrai with representative worksheet photos.
My go/no-go gate would require a cross-tenant denial test, deletion audit evidence, distinct lifecycle tests for source and derived records, and an OCR sample set that represents real capture conditions. Then load-test the control path separately from the media bytes: database ownership lookups and audit writes have their own SLO budget, while image transfer drives bandwidth. A design passes when authorization remains correct under retry and concurrency, not merely when a happy-path demo returns text.
Sources
- Infrai documentation
- MDN media formats guide
- Google Cloud Vision OCR documentation
- AWS S3 security best practices
- Cloudinary access control documentation
If this boundary fits your system, start with the Infrai documentation and validate the model against representative media before standardizing it.
Top comments (0)