# 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.

```mermaid
sequenceDiagram
  participant B as Browser
  participant A as Your server
  participant L as Lockwell
  B->>A: Request an upload URL
  A->>L: Sign a PUT URL using the tenant key
  L-->>A: Short-lived signed URL
  A-->>B: Return the URL without credentials
  B->>L: PUT the file bytes directly to the signed URL
  L-->>B: 200 OK; application never handles bytes
```

## 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](/guide/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.

::: code-group

```ts [Node]
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 [Go]
import "github.com/RusticStack/lockwell-sdk-go/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 [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.

::: code-group

```ts [Node]
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 [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 [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 [Node]
// 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.

::: warning 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 [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](/guide/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](/guide/app-kit). The end-to-end provision, sign, and upload story.
- [Edge runtimes](/guide/edge-runtimes). Mint signed URLs from a Cloudflare Worker or Vercel Edge function.
- [Webhooks](/guide/webhooks). React to the object once the browser finishes uploading.
