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.
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'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)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
// 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
// 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.