S3 malware scanning is one of those topics that tends to be missing from architecture diagrams. We build uploads with presigned URLs, we care about file sizes and CORS, and in the end a PDF from a complete stranger lands in our bucket. From there, someone in HR might open it, a thumbnail generator processes it or an AI pipeline extracts its text. At that point the question is fair: how do you actually know there is nothing nasty inside that file?
Earlier today I showed how to build a secure file upload with S3 presigned URLs. This article is the logical next step. You will learn how GuardDuty Malware Protection for S3 checks every new file automatically, how to lock unscanned files with a bucket policy and how a Lambda function passes clean files on. The example is the job application portal of a fictional bakery that no longer wants to receive résumés by email. All the code is written with AWS CDK and TypeScript and is ready to use on GitHub.
In short
GuardDuty Malware Protection for S3 automatically scans new objects in a bucket, sets a tag with the result and reports it to EventBridge. With a bucket policy on that tag and a small Lambda function, you get an upload path where only checked files are processed further. The biggest stumbling block is the IAM role for GuardDuty: if a permission is missing, the plan stays in a warning state and files remain unscanned.
What you will learn in this article
- How GuardDuty Malware Protection for S3 works and what it costs
- Locking unscanned files with a tag and a bucket policy
- Processing scan results idempotently in a Lambda function
- The complete CDK stack with tests, deployment and a test run
- Typical mistakes with the IAM role, tags and KMS, and how to avoid them
Why S3 malware scanning belongs in every upload flow
S3 stores whatever you put into it. That is by design, because an object store is meant to keep data, not judge it. For a public upload, however, it means every file starts out as unknown cargo. Checking the file type helps, but it does not replace a look at the content. After all, a PDF with embedded malicious code is still a PDF.
For a long time, the answer was ClamAV inside a Lambda function. It works, but it is work: keeping signatures up to date, maintaining the layer or container image, watching memory limits and building workarounds for large files. Since 2024, GuardDuty Malware Protection for S3 has offered a managed service for this. You enable it per bucket, optionally only for certain prefixes, and AWS takes care of scan engines, signatures and scaling. According to AWS, several engines are used, both its own and third-party ones.
One detail I find particularly handy: you do not have to turn on GuardDuty for the whole account. Malware protection for S3 also runs as a standalone feature. Without an enabled GuardDuty detector you get no findings in the GuardDuty dashboard, but the scan result still arrives as a tag on the object and as an event on the default event bus. In other words, for our upload that is all we need.
How GuardDuty Malware Protection for S3 works
The flow at a glance
You create a so-called Malware Protection plan. First of all, the plan connects a bucket with an IAM role that GuardDuty is allowed to assume. Through this role, GuardDuty sets up a managed EventBridge rule that reports new objects, reads the file, scans it in an isolated environment and then writes the result to the object as a tag. In addition, GuardDuty publishes an event with the result. Finally, our own Lambda function reacts to that event.
🎬 Flow: S3 malware scanning with GuardDuty and Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de
Which scan results are there?
The tag is always called GuardDutyMalwareScanStatus. Its value is one of five possible results. For further processing, it matters that you do not just distinguish between “clean” and “infected”. Three of the results simply mean: we do not know.
| Value | Meaning | What our example does |
|---|---|---|
NO_THREATS_FOUND | Scan finished, nothing found | Copy to the clean bucket |
THREATS_FOUND | Scan finished, potential threat found | Delete the file, log the finding |
UNSUPPORTED | Scan skipped, for example because of password protection, extreme compression or quota limits | Manual review |
ACCESS_DENIED | GuardDuty could not read the object | Hold for review |
FAILED | Internal error during the scan | Flag for a human check |
Limits and costs
The most important quotas cannot be adjusted. GuardDuty scans objects up to 100 GB, extracts up to 100,000 files and goes up to 100 levels deep into nested archives. Per account and region you can protect up to 25 buckets and choose up to five prefixes per bucket. Therefore, for job applications that is more than enough. If you want to check huge data exports, on the other hand, you should know these limits in advance.
Beyond that, billing is based on the data scanned and the number of objects evaluated. The pricing example on the GuardDuty pricing page uses 0.09 US dollars per GB and 0.215 US dollars per 1,000 objects. On top of that, every account gets a monthly free tier of 1,000 requests and 1 GB per region. Let us be generous with our bakery: 20,000 applications per month at 2 MB each add up to 40 GB. That makes 3.60 US dollars for the data plus 4.30 US dollars for the objects, so just under 8 US dollars. Small amounts for S3 requests, object tags, EventBridge and Lambda come on top. However, prices differ by region, so take a look at the pricing page before you start.
The architecture for the application portal
Our fictional bakery Crumb receives applications as PDF, PNG or JPEG. The browser first requests an upload permission and then uploads the file directly to S3. After that, the frontend polls the status until the file is accepted or rejected. This is the classic asynchronous pattern: the API answers immediately, and the actual work happens in the background.
I deliberately use two buckets. The intake bucket accepts everything that is uploaded and lets nothing out as long as it has not been checked. The clean bucket only contains files without findings. In addition, all downstream systems, such as HR software, preview images or text recognition, only get read access to the clean bucket. That way, a misconfigured function cannot even get hold of an unscanned file.
A DynamoDB table keeps track of the status of each upload: SCANNING, ACCEPTED, REJECTED or NEEDS_REVIEW. Apart from that, three small Lambda functions do the rest. One issues the upload permission, one answers the status request and one processes the scan results from EventBridge.
Step by step: S3 malware scanning with AWS CDK
Prerequisites
- An AWS account with permissions for CloudFormation, IAM, S3, Lambda, DynamoDB, EventBridge, SQS and GuardDuty
- Node.js 22 or later and the AWS CLI with valid credentials
- A region where GuardDuty Malware Protection for S3 is available,
eu-central-1in this example -
jqfor the test run on the command line
Step 1: Create the project
We start with an empty folder and install the dependencies. Later we bundle the AWS SDK into the functions so that the versions match your code.
mkdir application-upload && cd application-upload npm init -y npm pkg set type=module npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm install -D aws-cdk aws-cdk-lib constructs esbuild tsx typescript vitest @types/aws-lambda @types/node mkdir bin lib src test The tsconfig.json gets a lean configuration for ES modules. The cdk.json tells the CDK CLI how to start the app.
{ "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node"], "noEmit": true }, "include": ["bin", "lib", "src", "test"] } { "app": "npx tsx bin/app.ts" } Step 2: The decision as a pure function
Before we touch anything in AWS, we write the logic that turns a scan result into a decision. It knows neither S3 nor DynamoDB and can therefore be tested in milliseconds. The default case at the end is what matters: anything that is not clearly clean or infected goes to manual review. A file that GuardDuty could not scan is not automatically harmless.
// src/scan-decision.ts // Pure logic without AWS calls: what happens to a file after the scan? export type ScanResultStatus = | 'NO_THREATS_FOUND' | 'THREATS_FOUND' | 'UNSUPPORTED' | 'ACCESS_DENIED' | 'FAILED'; export interface ScanResultDetail { scanStatus: 'COMPLETED' | 'SKIPPED' | 'FAILED'; resourceType: 'S3_OBJECT'; s3ObjectDetails: { bucketName: string; objectKey: string; eTag?: string; versionId?: string; }; scanResultDetails: { scanResultStatus: ScanResultStatus; threats: { name: string }[] | null; }; } export type Decision = | { action: 'accept'; uploadId: string; sourceKey: string; targetKey: string; eTag?: string } | { action: 'reject'; uploadId: string; sourceKey: string; threats: string[] } | { action: 'review'; uploadId: string; sourceKey: string; reason: string } | { action: 'ignore'; reason: string }; const INCOMING_PREFIX = 'incoming/'; export function decide(detail: ScanResultDetail): Decision { const key = detail.s3ObjectDetails.objectKey; if (!key.startsWith(INCOMING_PREFIX)) { return { action: 'ignore', reason: `key outside ${INCOMING_PREFIX}: ${key}` }; } const uploadId = key.slice(INCOMING_PREFIX.length); const status = detail.scanResultDetails.scanResultStatus; switch (status) { case 'NO_THREATS_FOUND': return { action: 'accept', uploadId, sourceKey: key, targetKey: `accepted/${uploadId}`, eTag: detail.s3ObjectDetails.eTag, }; case 'THREATS_FOUND': return { action: 'reject', uploadId, sourceKey: key, threats: (detail.scanResultDetails.threats ?? []).map((t) => t.name), }; default: // UNSUPPORTED, ACCESS_DENIED, FAILED: never let it through automatically return { action: 'review', uploadId, sourceKey: key, reason: status }; } } Step 3: Issue the upload permission
The first function checks the file type, generates a random ID and issues a presigned POST form. The content-length-range condition makes S3 itself reject files that are too large. Also, the key always starts with incoming/, because GuardDuty only scans this prefix. At the same time, we create an item in DynamoDB with the status SCANNING.
// src/create-upload.ts import { randomUUID } from 'node:crypto'; import { S3Client } from '@aws-sdk/client-s3'; import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; const s3 = new S3Client({}); const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const BUCKET = process.env.INTAKE_BUCKET!; const TABLE = process.env.UPLOADS_TABLE!; const MAX_BYTES = 10 * 1024 * 1024; // 10 MB const ALLOWED_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg']); export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => { const body = JSON.parse(event.body ?? '{}') as { fileName?: string; contentType?: string }; if (!body.fileName || !body.contentType || !ALLOWED_TYPES.has(body.contentType)) { return { statusCode: 400, body: JSON.stringify({ message: 'Only PDF, PNG or JPEG allowed' }) }; } const uploadId = randomUUID(); const key = `incoming/${uploadId}`; const post = await createPresignedPost(s3, { Bucket: BUCKET, Key: key, Conditions: [ ['content-length-range', 1, MAX_BYTES], ['eq', '$Content-Type', body.contentType], ], Fields: { 'Content-Type': body.contentType }, Expires: 300, // seconds }); await db.send( new PutCommand({ TableName: TABLE, Item: { uploadId, status: 'SCANNING', fileName: body.fileName.slice(0, 200), createdAt: new Date().toISOString(), expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 3600, // TTL: 30 days }, }), ); return { statusCode: 201, body: JSON.stringify({ uploadId, url: post.url, fields: post.fields }) }; }; In contrast, the status endpoint is deliberately short. It only returns what the frontend should display and does not reveal internal bucket names or keys.
// src/get-upload.ts import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const TABLE = process.env.UPLOADS_TABLE!; export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => { const uploadId = event.pathParameters?.uploadId ?? ''; const { Item } = await db.send(new GetCommand({ TableName: TABLE, Key: { uploadId } })); if (!Item) return { statusCode: 404, body: JSON.stringify({ message: 'Unknown upload' }) }; // Only return what the frontend needs, no internal keys return { statusCode: 200, body: JSON.stringify({ uploadId, status: Item.status, fileName: Item.fileName }) }; }; Step 4: Process the scan result
Now for the core. EventBridge invokes this function with the event GuardDuty Malware Protection Object Scan Result. It gets the decision from step 2 and carries it out. Three details make the difference between a demo and production.
First, EventBridge delivers events at least once, so possibly twice. That is why the function first checks in DynamoDB whether the upload is still SCANNING and only writes the result back with a condition. You can read more about this in my article on idempotency in AWS Lambda. Second, the function uses CopySourceIfMatch to copy only the exact version whose ETag is in the scan result. If someone uploads a second file under the same key within the five-minute validity, the copy fails instead of waving through an unscanned file. Third, the function rethrows unexpected errors so that EventBridge retries and the event eventually ends up in the dead-letter queue.
// src/on-scan-result.ts import { CopyObjectCommand, DeleteObjectCommand, S3Client, S3ServiceException } from '@aws-sdk/client-s3'; import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { EventBridgeEvent } from 'aws-lambda'; import { decide, type ScanResultDetail } from './scan-decision.js'; const s3 = new S3Client({}); const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const INTAKE = process.env.INTAKE_BUCKET!; const CLEAN = process.env.CLEAN_BUCKET!; const TABLE = process.env.UPLOADS_TABLE!; type ScanEvent = EventBridgeEvent<'GuardDuty Malware Protection Object Scan Result', ScanResultDetail>; export const handler = async (event: ScanEvent): Promise<void> => { const decision = decide(event.detail); if (decision.action === 'ignore') { console.info(JSON.stringify({ msg: 'ignored', reason: decision.reason })); return; } // Idempotency: EventBridge delivers at least once const { Item } = await db.send(new GetCommand({ TableName: TABLE, Key: { uploadId: decision.uploadId } })); if (Item?.status !== 'SCANNING') { console.info(JSON.stringify({ msg: 'already processed', uploadId: decision.uploadId, status: Item?.status })); return; } if (decision.action === 'accept') { try { await s3.send( new CopyObjectCommand({ Bucket: CLEAN, Key: decision.targetKey, CopySource: `${INTAKE}/${decision.sourceKey}`, // Only copy exactly the version that was scanned CopySourceIfMatch: decision.eTag, }), ); } catch (err) { if (err instanceof S3ServiceException && err.name === 'PreconditionFailed') { await setStatus(decision.uploadId, 'NEEDS_REVIEW', { reason: 'object changed after scan' }); return; } throw err; // EventBridge retries, then the DLQ takes over } await s3.send(new DeleteObjectCommand({ Bucket: INTAKE, Key: decision.sourceKey })); await setStatus(decision.uploadId, 'ACCEPTED', { cleanKey: decision.targetKey }); return; } if (decision.action === 'reject') { await s3.send(new DeleteObjectCommand({ Bucket: INTAKE, Key: decision.sourceKey })); await setStatus(decision.uploadId, 'REJECTED', { threats: decision.threats }Source:https://dev.to/sascha-ms/s3-malware-scanning-check-uploads-with-guardduty-5814Related posts

How We Built Kisan Saathi in 24 Hours: Surviving 3 AM Bugs, Voice AI in Kannada, and Building for Real Farmers 🌾
