Presigned URLs and Secure Direct Uploads with Lioran S3
Your frontend usually should not receive your storage administrator password.
That sentence is obvious enough to print on a coffee mug, yet credentials still somehow end up inside browser bundles.
Lioran S3 V1 Pre-Alpha provides expiring signed URLs for delegated GET, PUT, and HEAD operations.
It is built by Lioran Developer Solutions under Lioran Group, led by Swaraj Puppalwar.
The pattern
A trusted backend authenticates to Lioran S3.
The backend generates a short-lived signed URL.
The untrusted or less-trusted client receives only that URL.
Browser
|
| asks backend for upload permission
v
Application Backend
|
| authenticated storage request
v
Lioran S3
|
| returns expiring signed URL
v
Application Backend
|
| sends URL
v
Browser
|
| direct PUT
v
Lioran S3
Create a client
import {
BastionClient,
} from "@liorans3/driver";
const client = new BastionClient(
process.env.LIORAN_S3_URI!
);
const bucket =
client.bucket("uploads");
Signed GET
const url =
await bucket.presignGet(
"private/report.pdf",
{
expiresIn: 3600,
}
);
console.log(url);
The caller can temporarily download that object without receiving the backend's credentials.
Signed PUT
const uploadUrl =
await bucket.presignPut(
"incoming/avatar.png",
{
expiresIn: 900,
}
);
Then your frontend can upload directly:
const file =
input.files?.[0];
if (!file) {
throw new Error("No file selected");
}
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"content-type":
file.type ||
"application/octet-stream",
},
});
Signed HEAD
const headUrl =
await bucket.presignHead(
"private/report.pdf",
{
expiresIn: 600,
}
);
This is useful when a client needs to verify object metadata without downloading the payload.
Backend route example
import express from "express";
import {
BastionClient,
} from "@liorans3/driver";
const app = express();
const storage =
new BastionClient(
process.env.LIORAN_S3_URI!
);
app.post(
"/api/uploads/avatar",
async (req, res) => {
// Authenticate your application user first.
const objectKey =
`avatars/${crypto.randomUUID()}.png`;
const bucket =
storage.bucket("users");
const uploadUrl =
await bucket.presignPut(
objectKey,
{
expiresIn: 5 * 60,
}
);
res.json({
objectKey,
uploadUrl,
});
}
);
The browser never sees:
- admin password
- access-key secret
- storage connection URI
Expiration matters
Do not issue week-long upload URLs when five minutes is enough.
Use the shortest practical lifetime.
await bucket.presignPut(
key,
{
expiresIn: 300,
}
);
Server signing secret
Lioran S3 signs delegated URLs using an HMAC signing secret.
Production deployments should configure a persistent, high-entropy signing secret so URLs behave consistently across process restarts.
For example:
BASTION_SIGNING_SECRET=replace-with-a-long-random-secret
Do not publish it.
Maximum TTL
The server also supports a maximum signed-URL TTL.
This lets infrastructure operators enforce an upper bound even if an application asks for something excessively long.
CORS
Direct browser uploads require correct CORS configuration.
Production should restrict allowed origins:
BASTION_CORS_ORIGINS=https://app.example.com
Avoid a permissive production origin policy unless your threat model genuinely permits it.
Access keys for backend services
Instead of a human user's password, a service can authenticate using programmatic access keys.
const client =
new BastionClient({
accessKey:
process.env.LIORAN_ACCESS_KEY!,
secretKey:
process.env.LIORAN_SECRET_KEY!,
host:
"storage.example.com",
isTls: true,
});
That makes secret rotation easier and separates automation credentials from human login credentials.
Security checklist
For production-style evaluation:
- terminate public traffic over HTTPS
- rotate bootstrap admin credentials
- keep the signing secret stable and private
- restrict CORS
- use short signed-URL lifetimes
- do not expose access-key secrets to browsers
- rotate compromised access keys
- log identifiers, not raw secrets
- avoid embedding passwords in source code
Signed URLs are small primitives, but they dramatically improve object-storage integration because your application backend stops being a mandatory data proxy.
Top comments (0)