Security
Server-owned keys, ACL, and verified file metadata.
dimah-s3 follows a presign-first security model: AWS credentials remain on the server, while clients upload directly to storage via short-lived signed URLs.
Never expose S3 credentials in the browser or client bundles. S3 clients must always run server-side.
Security model
- Server owns keys: The client requests an upload for a named
route. The server decides the destination key path (avatar/{uuid}/{fileName}or customized viaobject). - Namespace isolation: Confirm, download, and delete follow-ups are restricted to the route's
keyPrefix. Requests referencing keys outside the prefix fail withINVALID_KEY. - Verified metadata via
HeadObject: Presign payloads (size/type) are untrusted. Verified file sizes and Content-Types are confirmed server-side viaHeadObjectinupload.onConfirmed. - Guards & Authorization: Route
guardand feature guards (upload.guard,download.guard,delete.guard) control per-user authorization.
Scoping objects & user tenancy
Use upload.object to isolate files by tenant or user ID:
import { dimahS3, errors, route } from "@dimah-s3/server";
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
routes: {
avatar: route({
guard: async ({ request }) => {
const session = await getSession(request);
if (!session) throw errors.unauthorized();
},
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
object: async ({ request }) => {
const session = await getSession(request);
return {
folder: `users/${session.userId}`,
// Generates: avatar/users/{userId}/{uuid}/{fileName}
};
},
onConfirmed: async ({ key, contentLength, request }) => {
// Trusted size & key confirmed from S3 HeadObject
const session = await getSession(request);
await db.user.update({
where: { id: session.userId },
data: { avatarKey: key, avatarSize: contentLength },
});
},
},
download: true,
delete: true,
}),
},
});Object ACL
Uploads are private by default. Set acl on the route or return it dynamically from upload.object:
avatar: route({
upload: {
acl: "public-read",
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
},
}),Hook error mapping
| Hook Location | Plain Error Thrown | errors.* / APIError |
|---|---|---|
guard / *Guard / object | 403 Forbidden | Custom status & code |
on* lifecycle hooks (onConfirmed, onDeleted) | 500 Internal Error | Custom status & code |
Frequently asked questions
The server checks MIME headers at presign and HeadObject. If you need strict magic-byte validation, use sniffContentType / matchesMagicBytes in onConfirmed. Throwing an error from onConfirmed automatically deletes the uploaded object from S3:
import { matchesMagicBytes, sniffContentType } from "@dimah-s3/core";
avatar: route({
upload: {
onConfirmed: async ({ key, client }) => {
// Fetch the first few bytes
const res = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: "bytes=0-15",
}),
);
const bytes = new Uint8Array(await res.Body.transformToByteArray());
if (
!matchesMagicBytes(bytes, ["image/png", "image/jpeg", "image/webp"])
) {
throw errors.fileTypeNotAllowed("Invalid image magic bytes");
}
},
},
});Use guard on download/delete or use the @dimah-s3/db plugin for automatic scope isolation:
avatar: route({
delete: {
guard: async ({ key, request }) => {
const session = await getSession(request);
const isOwner = await checkAvatarOwnership(session.userId, key);
if (!isOwner) throw errors.forbidden();
},
},
}),