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 is GET-only by design. The native API supports signed write 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.
  • The native signed PUT URL is the sanctioned browser-upload path. The S3 presigner stays GET-only.

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.

Released under the Apache-2.0 License. License