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/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)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.
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.
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 uploadcontentTypeoverrides the browser'sContent-Typeso the stored object carries exactly this type.contentLengthMaxrejects an upload whose declaredContent-Lengthexceeds it (413) before any blob is written. Chunked uploads with no declared length fall back to the server's globalmax_object_size.checksum(alg, value)is injected as theX-Lockwell-Checksum-<alg>header; the server verifies the body against it and rejects a mismatch (400).idempotencyKeyis injected as theIdempotency-Keyheader; 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/).
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=.
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.
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.
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,
},
],
});_, 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,
}},
})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
// 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.