Security
Server-owned keys, route namespaces vs ownership, 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.
Public NEXT_PUBLIC_ keys in a browser bundle are a leak, not a configuration
shortcut:
export const awsS3 = new S3Client({
credentials: {
accessKeyId: process.env.NEXT_PUBLIC_S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.NEXT_PUBLIC_S3_SECRET_ACCESS_KEY!,
},
});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 —
keyPrefixdefaults to the route name. Upload generates keys under it; confirm, download, and delete reject keys outside it (INVALID_KEY). This check always runs in@dimah-s3/server. - Verified metadata via
HeadObject— Presign payloads (size/type) are untrusted. Stored byte length andContent-Typemetadata are read server-side viaHeadObjectinupload.onConfirmed. - Guards and authorization — Route
guardand feature guards (upload.guard,download.guard,delete.guard) control per-user authorization. Namespace isolation is not ownership.
Disabling keyPrefix removes route isolation
keyPrefix: false allows follow-up operations to target any safe key in the
bucket. Use it only when strict route or operation guards enforce the complete
ownership policy.
Route namespace vs ownership
keyPrefix and a guard answer different questions:
| Check | Runs | Answers | Typical failure |
|---|---|---|---|
keyPrefix | Always, in @dimah-s3/server | Is this key inside this route's folder? | INVALID_KEY |
Guards or db() | Only if you add them | May this caller touch this object? | 401 / 403 / 404 |
The same prefix is used on the way in and the way out. With two routes:
avatar: route({ upload: { fileTypes: ["image/*"] }, delete: true }),
document: route({ upload: { fileTypes: ["application/pdf"] }, delete: true }),- Upload on
avatarcreatesavatar/{uuid}/photo.jpg— the client does not send a key. DELETE ?route=avatar&key=avatar/{uuid}/photo.jpgis in-namespace.DELETE ?route=avatar&key=document/{uuid}/cv.pdfisINVALID_KEY.
db() does not replace that prefix check. It stores a route column for listings; its guards look up (bucket, key) and compare scope. A key from another route is still rejected by the server before those guards run.
Enabled delete is not per-user authorization
delete: true or download: true with no ownership guard means anyone who
can call the endpoint and knows a key in that namespace can delete or
download that object. A UUID in the path makes keys hard to guess; it is
not an access check. Returning folder: users/{userId} from upload.object
only changes the key shape.
A guard that only checks "is the user signed in?" still lets any signed-in
user target any namespaced key they know. Enforce ownership in
download.guard / delete.guard, or attach db().
Scoping objects and 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 stored size and server-owned key 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,
}),
},
});Returning folder: users/{userId} only shapes the key. The route guard above
requires a session; it does not stop one signed-in user from targeting another
user's key. Add download.guard / delete.guard (or db()) for
ownership.
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
Read the object header with your configured, server-only S3 client and inspect it
in onConfirmed. The hook context contains the bucket and key, but not an S3
client:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { sniffContentType } from "@dimah-s3/core";
import { dimahS3, errors, route } from "@dimah-s3/server";
import { awsS3 } from "./aws-s3";
const bucket = process.env.S3_BUCKET!;
const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp"]);
export const s3 = dimahS3({
client: awsS3,
bucket,
routes: {
avatar: route({
upload: {
fileTypes: ["image/png", "image/jpeg", "image/webp"],
onConfirmed: async ({ key, fileName }) => {
const response = await awsS3.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: "bytes=0-15",
}),
);
if (!response.Body) throw errors.objectNotFound();
const bytes = await response.Body.transformToByteArray();
const detectedType = sniffContentType(bytes);
if (!detectedType || !allowedTypes.has(detectedType)) {
throw errors.fileTypeNotAllowed(fileName ?? key, detectedType);
}
},
},
}),
},
});The normal presign and HeadObject checks still run. If this stricter check
throws, dimah-s3 performs a compensating delete so the rejected object is not
left in S3.
keyPrefix only keeps avatar keys off the document route. It does not
know which user owns a file. Use download.guard / delete.guard, or the
@dimah-s3/db plugin for automatic scope isolation:
avatar: route({
delete: true,
}),
avatar: route({
delete: {
guard: async ({ key, request }) => {
const session = await getSession(request);
const isOwner = await checkAvatarOwnership(session.userId, key);
if (!isOwner) throw errors.forbidden();
},
},
}),