Skip to content

Signed URLs

A native signed URL is a short-lived, single-object URL that carries its own authorization in a token query parameter. The browser uses it with no credential.

Mint it server-side, hand the URL to the browser, and the browser PUTs or GETs the object bytes directly to Lockwell. The object body never round-trips through your application, and your access-key secret (or admin token) never reaches the client.

The S3 presigner supports GET, PUT, HEAD, and DELETE. The native API also supports signed GET and PUT URLs, so you can sign a direct browser upload as well as a download.

The security shape

  • The URL is method- and resource-bound. A GET URL can only GET, a PUT URL can only PUT, and each is bound to exactly one bucket + key.
  • It is time-limited by a TTL you set (clamped server-side to security.max_presign_ttl).
  • It can never exceed the minting key's scope. The server re-checks the key's scope and bucket policy both when the URL is minted and again when it is used. A read-only key minting a PUT URL is denied with a 403.
  • The browser holds only the URL. It never sees the access-key secret, the native bearer token, or the admin token.

When the URL is used, the server also re-checks the underlying key's revocation. A tampered, expired, wrong-method, wrong-resource, revoked, or scope-exceeding URL is rejected (401 or 403).

Mint server-side

You can mint directly from the native client (signUrl) or, more conveniently, from the app kit (signedUploadUrl / signedDownloadUrl), which also returns the method and headers the browser should send. The kit takes the tenant's creds and binds (and caches) a per-tenant client under the hood.

ts
import { LockwellKit } from "@kelphect/sdk";

const kit = new LockwellKit({
  admin: { endpoint: "https://admin.example.com", token: process.env.LOCKWELL_ADMIN_TOKEN },
  native: { endpoint: "https://objects.example.com" },
});

const creds = { accessKeyId, secretKey }; // loaded from your own secure store

// Upload (PUT):
const up = await kit.signedUploadUrl(creds, "inbox", "photo.jpg", {
  ttlSeconds: 300,
  contentType: "image/jpeg",
});
// up.url, up.method === 'PUT', up.headers (e.g. { 'content-type': 'image/jpeg' })

// Download (GET):
const down = await kit.signedDownloadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300 });
// down.url, down.method === 'GET'
go
import "github.com/KelpHect/lockwell/pkg/lockwellkit"

creds := lockwellkit.TenantCreds{AccessKeyID: accessKeyID, SecretKey: secretKey}

// Upload (PUT):
up, err := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg", lockwellkit.SignedUploadURLInput{
    TTLSeconds:  300,
    ContentType: "image/jpeg",
})
// up.URL, up.Method == "PUT", up.Headers

// Download (GET):
downURL, err := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300)
java
import com.lockwell.sdk.kit.KitTypes.TenantCredentials;
import java.time.Duration;

TenantCredentials creds = new TenantCredentials(accessKeyId, secretKey);

// Upload (PUT):
var up = kit.signedUploadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5), "image/jpeg");
// up.url(), up.method() == "PUT", up.contentType()

// Download (GET):
var down = kit.signedDownloadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(15));

If you already hold a NativeClient, mint directly with native.signUrl({ method, bucket, key, ttlSeconds }) (Node), native.SignURL(ctx, lockwellnative.SignURLInput{...}) (Go), or nativeClient.signUrlResult("GET"|"PUT", bucket, key, ttlSeconds) (Java). method must be GET or PUT.

Separate internal and public origins

ERP deployments often run the backend against an internal service URL but need browser URLs that resolve against a different public origin. In the Java app kit, set signedUrlPublicOrigin on the builder: the backend still mints against nativeEndpoint, but the absolute URL handed to the browser is built from the public origin.

java
LockwellKit kit = LockwellKit.builder()
    .adminEndpoint("https://admin.internal.svc")
    .adminToken(adminToken)
    .nativeEndpoint("https://objects.internal.svc")     // backend reaches Lockwell here
    .signedUrlPublicOrigin("https://objects.example.com") // browser reaches Lockwell here
    .signedUrlMaxTtl(Duration.ofMinutes(5))              // client-side cap (server cap still applies)
    .build();

signedUrlMaxTtl is an optional client-side cap: the kit clamps a requested TTL down to it before minting, and the server independently clamps to security.max_presign_ttl. The effective TTL is min(requested, client cap, server cap). When signedUrlPublicOrigin is unset, the kit falls back to nativeEndpoint (the legacy behavior).

Browser upload constraints (Java)

For a browser-direct PUT, the minting caller can pin properties the browser cannot be trusted to send correctly. These constraints are HMAC-covered in the signed token and enforced by the server at dispatch time — the browser cannot drop them.

java
import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions;
import java.security.MessageDigest;
import java.util.Base64;

byte[] body = invoiceBytes;
String sha256 = Base64.getEncoder().encodeToString(
    MessageDigest.getInstance("SHA-256").digest(body));

BrowserSignedUrl up = kit.signedUploadUrl(creds, "fiscal-archive", "2026/0001.pdf",
    Duration.ofMinutes(5),
    new SignedUrlOptions()
        .contentType("application/pdf")          // pins the stored Content-Type
        .contentLengthMax(10L * 1024 * 1024)     // rejects a larger upload (413)
        .checksum("SHA256", sha256)              // verifies the body bytes
        .idempotencyKey("invoice-2026-0001"));   // a retry collapses onto the first upload
  • contentType overrides the browser's Content-Type so the stored object carries exactly this type.
  • contentLengthMax rejects an upload whose declared Content-Length exceeds it (413) before any blob is written. Chunked uploads with no declared length fall back to the server's global max_object_size.
  • checksum(alg, value) is injected as the X-Lockwell-Checksum-<alg> header; the server verifies the body against it and rejects a mismatch (400).
  • idempotencyKey is injected as the Idempotency-Key header; a browser retry collapses onto the first upload's result.

Prefix-scoped uploads

Instead of an exact key, a PUT mint can authorize any key under a prefix — the browser chooses the suffix. This is useful when the browser generates its own object key (e.g. a random id under imports/).

java
BrowserSignedUrl up = kit.signedUploadUrl(creds, "imports", null,
    Duration.ofMinutes(5),
    new SignedUrlOptions().keyPrefix("imports/"));

The returned URL is a prefix-authorized base URL whose query already contains the token. The browser inserts the full object key under the prefix into the path before the query string; do not append a suffix after ?token=.

js
function signedPrefixUploadUrl(baseUrl, objectKey) {
  const url = new URL(baseUrl);
  const encodedKey = objectKey.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
  url.pathname = url.pathname.replace(/\/?$/, "/") + encodedKey;
  return url.toString();
}

const objectKey = `imports/${crypto.randomUUID()}.json`;
await fetch(signedPrefixUploadUrl(up.url(), objectKey), {
  method: "PUT",
  body: file,
});

keyPrefix is PUT-only and mutually exclusive with an exact key. The server re-checks the minting key's live scope against the browser-chosen full object key at access time, so the URL can never exceed the key.

Browser download response overrides (Java)

For a browser-direct GET, pin the response headers so a fiscal-PDF or export download is predictable.

java
BrowserSignedUrl dl = kit.signedDownloadUrl(creds, "fiscal-archive", "2026/0001.pdf",
    Duration.ofMinutes(5),
    new SignedUrlOptions()
        .responseContentType("application/pdf")
        .responseContentDisposition("attachment; filename=\"invoice-2026-0001.pdf\"")
        .auditReason("ERP export invoice-2026-0001"));

These map to the S3-compatible response-content-type / response-content-disposition query parameters, which an authenticated native GET also honors directly. auditReason is stored on the signed-URL mint/use audit rows and is signed into the URL token, so the browser cannot rewrite the reason attached to an export.

Configure browser CORS once

For a browser fetch() from your app origin, configure CORS on the bucket before handing out signed URLs. This is a bucket admin operation, so the app kit uses a transient admin-scoped key and revokes it immediately; your stored tenant data key stays read,write,delete only.

ts
await kit.configureBucketCors("acme", "inbox", {
  rules: [
    {
      id: "browser-direct",
      allowedOrigins: ["https://app.example.com"],
      allowedMethods: ["GET", "HEAD", "PUT"],
      allowedHeaders: ["content-type"],
      exposeHeaders: ["ETag"],
      maxAgeSeconds: 600,
    },
  ],
});
go
_, err := kit.ConfigureBucketCORS(ctx, "acme", "inbox", lockwellnative.CORSConfiguration{
    Rules: []lockwellnative.CORSRule{{
        ID:             "browser-direct",
        AllowedOrigins: []string{"https://app.example.com"},
        AllowedMethods: []string{"GET", "HEAD", "PUT"},
        AllowedHeaders: []string{"content-type"},
        ExposeHeaders:  []string{"ETag"},
        MaxAgeSeconds:  600,
    }},
})
java
import com.lockwell.sdk.nativeapi.NativeTypes.CORSConfiguration;
import com.lockwell.sdk.nativeapi.NativeTypes.CORSRule;
import java.util.List;

kit.configureBucketCORS("acme", "inbox", new CORSConfiguration(List.of(
    new CORSRule("browser-direct",
        List.of("https://app.example.com"),
        List.of("GET", "HEAD", "PUT"),
        List.of("content-type"),
        List.of("ETag"),
        600))));

The signed URL token must still match the browser's method and object. A preflight for PUT against a signed GET URL is rejected, and a matching CORS rule does not make anonymous or unsigned object access possible.

A realistic upload flow

The pattern is always the same. The browser asks your server for a URL, then uploads directly to Lockwell.

Server route

ts
// POST /api/upload-url  { key, contentType }
app.post("/api/upload-url", async (req, res) => {
  const tenant = resolveTenantFromSession(req); // NEVER from caller input
  const creds = await loadCreds(tenant); // from your own secure store

  const up = await kit.signedUploadUrl(creds, "inbox", req.body.key, {
    ttlSeconds: 300,
    contentType: req.body.contentType,
  });

  // Return ONLY the URL + how to use it. No key, no secret, no token.
  res.json({ url: up.url, method: up.method, headers: up.headers });
});

The tenant is resolved server-side from the authenticated session, never from a request path. The tenant on the Lockwell side is also derived from the per-tenant token, so cross-tenant access is structurally impossible.

Never trust a tenant from caller input Resolve the tenant from the authenticated session, not from a request

path, query parameter, or body field. A signed URL inherits the minting key's tenant and scope, so signing with the wrong tenant's creds is the only way to cross a boundary. :::

Browser

js
// 1) Ask our server for a signed URL.
const r = await fetch("/api/upload-url", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ key: "photo.jpg", contentType: file.type }),
});
const { url, method, headers } = await r.json();

// 2) PUT the file bytes straight to Lockwell. The browser holds no credential.
await fetch(url, { method, headers, body: file });

A download is the mirror image. Mint a GET URL server-side and put it in an <img src>, an <a href>, or a fetch(url). The browser reads the bytes directly from Lockwell until the URL expires.

Notes and non-goals

  • A signed URL is for a single object and a single method. To let a browser list a bucket or perform multiple operations, mint a URL per object or proxy the call through your server with the native client.
  • Lockwell has no public or anonymous buckets. A signed URL is the only way to grant credential-free object access, and it is always scoped and time-limited. See the three surfaces for the full security model.
  • Use the S3 presigned PUT or the native signed PUT URL according to the client surface your application already uses.

Next steps

  • The app kit. The end-to-end provision, sign, and upload story.
  • Edge runtimes. Mint signed URLs from a Cloudflare Worker or Vercel Edge function.
  • Webhooks. React to the object once the browser finishes uploading.

Source-available under PolyForm Noncommercial 1.0.0; commercial use requires a written grant. License