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/lockwell/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 native.signUrlResult("GET"|"PUT", bucket, key, ttlSeconds) (Java). method must be GET or PUT.

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