Security
Server-owned keys, route namespaces vs ownership, upload tickets, 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 (avatar/{uuid}/{name}, or whatupload.objectreturns). - Signed upload tickets — Presign returns an HMAC-signed ticket that names the route, key, and declared file. Confirm and the later multipart calls send only the ticket, so a client cannot point them at another key. A ticket can be bound to a user.
- Namespace isolation —
keyPrefixdefaults to the route name. Upload generates keys under it; download and delete reject keys outside it (KEY_INVALID). This check always runs in@dimah-s3/server. - Verified metadata via
HeadObject— Presign payloads (size and type) are untrusted. Confirm reads the stored size and type withHeadObjectand checks them against the ticket and the route limits beforeonConfirmedruns. - Guards and authorization — The instance
guard, routeguard, and 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 download and delete 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? | KEY_INVALID |
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.pdfisKEY_INVALID.
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 keyFolder: 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
Resolve the user once in a guard, pass it on as locals,
and use it to shape keys and bind tickets:
import { dimahS3, route, s3Error } from "@dimah-s3/server";
import { awsS3 } from "./aws-s3";
declare module "@dimah-s3/server" {
interface DimahS3Locals {
userId?: string;
}
}
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
guard: async ({ request }) => {
const session = await getSession(request);
if (!session) throw s3Error("UNAUTHORIZED");
return { locals: { userId: session.userId } };
},
ticketSubject: ({ locals }) => locals.userId,
routes: {
avatar: route({
upload: {
fileTypes: ["image/*"],
maxFileSize: 2 * 1024 * 1024,
object: ({ locals }) => ({
keyFolder: `users/${locals.userId}`,
// Generates: avatar/users/{userId}/{uuid}/{name}
}),
onConfirmed: async ({ key, object, locals }) => {
// Size from S3 HeadObject, not from the browser
await db.user.update({
where: { id: locals.userId },
data: { avatarKey: key, avatarSize: object.size },
});
},
},
download: true,
delete: true,
}),
},
});ticketSubject binds each ticket to the user who started the upload. A
leaked ticket alone cannot confirm or continue that upload for anyone else.
onConfirmed runs at least once per upload, so keep it safe to replay, as
this update is.
keyFolder: users/{userId} only shapes the key. The 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,
},
}),Request hardening
- JSON only — Every body endpoint, plugin endpoints included, accepts
application/jsononly. A cross-site HTML form cannot send that, so a simple form post returns415 UNSUPPORTED_MEDIA_TYPE. - Body cap —
maxRequestBodySize(1 MiB by default) is counted on the stream before parsing. - Signed headers — A presigned PUT signs every header the server returns
in
headers: type, disposition, metadata, ACL, tags, storage class, and checksum. A custom client must send exactly those headers. - Inline allow-list — Downloads open in the browser only for raster
images, PDF, audio, video, and plain text, unless you set
download.inlineTypes. HTML, SVG, and other types that run as a page are always downloaded as attachments. - Hardened proxy —
GET /filesends a sandbox CSP,nosniff, andCross-Origin-Resource-Policyon every response.
Hook error mapping
| Thrown | Result |
|---|---|
s3Error(code) or another APIError | Its own status and code |
A plain Error, anywhere | 500 INTERNAL_ERROR, logged, passed to onError |
Any throw from your upload.onConfirmed | 500 INTERNAL_ERROR; the object is kept |
A guard that returns anything other than nothing or { locals } (for example
true or false) is a config error: it is logged and returns 500. It never
allows the call. See Errors.
Frequently asked questions
Set verifyContent: true on the route's upload. After confirm, the server
reads the first bytes of the object and checks them against the declared
type. A mismatch deletes the object and returns FILE_CONTENT_MISMATCH:
avatar: route({
upload: {
fileTypes: ["image/png", "image/jpeg", "image/webp"],
verifyContent: true,
},
}),verifyContent also turns on checksum, so the bytes that were checked are
the bytes that stay. For a stricter rule, inspect the object yourself in
confirmGuard: a rejection there deletes the object too. sniffContentType
from @dimah-s3/core returns the detected type of a byte prefix.
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, locals }) => {
const isOwner = await checkAvatarOwnership(locals.userId, key);
if (!isOwner) throw s3Error("FORBIDDEN");
},
},
}),