Private asset upload
Cognito, S3, SQS, DynamoDB
Day Five #
Today we turn infrastructure into the first useful vertical slice of an asset-processing application. A signed-in user can upload a PNG or JPEG. The original remains private, S3 sends the upload to the existing queue, a worker validates it, and CloudFront serves only the validated result.
Goal #
For every preview environment:
- authenticate the API with a Cognito user pool
- let an authenticated user request a short-lived, constrained S3 upload form
- store the asset's ownership and state in DynamoDB
- send S3 upload events to the existing SQS queue
- validate the uploaded bytes in the worker Lambda
- publish only validated assets through the existing CloudFront distribution
The outcome is a real boundary: browsers never receive AWS credentials, never receive permission to read the asset bucket, and cannot choose an arbitrary S3 key.
The product flow #
Signed-in browser
|
| POST /assets (JWT)
v
+----------------------+ create: uploading +-------------------+
| CreateUpload Lambda | --------------------------> | DynamoDB Assets |
| returns signed form | | owner + state |
+----------------------+ +-------------------+
|
| POST form, valid for 5 minutes
v
+----------------------+ ObjectCreated +-------------------+
| Private S3 bucket | --------------------------> | SQS assets queue |
| uploads/originals/* | +-------------------+
+----------------------+ |
v
+-------------------+
| Worker Lambda |
| inspect + promote |
+-------------------+
| |
update state | | copy validated output
v v
DynamoDB Assets S3 processed/assets/*
|
v
CloudFront /assets/*
The original upload path has no CloudFront behaviour. Only /assets/* maps to the processed prefix, so an original object cannot become public merely because somebody knows its key.
Direct S3 uploads #
Sending files through API Gateway and Lambda adds a small request-body limit, an unnecessary transfer hop, and Lambda duration cost. A pre-signed POST lets the API retain authority over what may be uploaded while the browser sends the bytes directly to S3.
The API creates the record first and generates the object key itself. The signed policy then restricts:
- the exact bucket and object key
- the MIME type
- a maximum file size
- a five-minute validity window
The client cannot use the form to upload a different type, a larger file, or a second object.
Add the required packages #
Bundle the AWS SDK modules with the Lambda code instead of relying on the version included in a managed runtime. This makes builds reproducible.
npm install \
@aws-sdk/client-dynamodb \
@aws-sdk/client-s3 \
@aws-sdk/s3-presigned-post
npm install --save-dev esbuild
Use NodejsFunction for the new TypeScript Lambdas so CDK bundles those dependencies:
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
Model the assets #
DynamoDB owns application metadata. S3 is the source of truth for object bytes; SQS is responsible for at-least-once delivery; the table records who owns the asset and whether the application has accepted it.
{
"assetId": "307d0b55-05d1-4c0f-a0fb-4c3e1083e3df",
"ownerId": "cognito-user-sub",
"status": "uploading",
"contentType": "image/png",
"sourceKey": "uploads/originals/307d0b55-05d1-4c0f-a0fb-4c3e1083e3df.png",
"createdAt": "2026-06-21T10:15:00.000Z",
"updatedAt": "2026-06-21T10:15:00.000Z",
"expiresAt": 1782036900
}
expiresAt is the DynamoDB TTL attribute for abandoned uploads. The worker removes it once an asset is accepted, so a completed asset is retained according to the application's retention policy.
CDK - identity, storage, and metadata #
Add these imports to lib/stack.ts:
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';
import * as authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import { join } from 'node:path';
The current stack is explicitly a pull-request preview stack. Its resources may be destroyed on PR close. Keep that decision visible in code; production environments must retain their data.
const isPreview = pr !== 'production';
const cleanupPolicy = isPreview
? cdk.RemovalPolicy.DESTROY
: cdk.RemovalPolicy.RETAIN;
const assetBucket = new s3.Bucket(this, 'AssetBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
removalPolicy: cleanupPolicy,
autoDeleteObjects: isPreview,
cors: [{
allowedOrigins: ['https://*.cloudfront.net'],
allowedMethods: [s3.HttpMethods.POST],
allowedHeaders: ['content-type'],
exposedHeaders: ['etag'],
maxAge: 300,
}],
lifecycleRules: [{
abortIncompleteMultipartUploadAfter: cdk.Duration.days(1),
noncurrentVersionExpiration: cdk.Duration.days(30),
}],
});
const assets = new dynamodb.Table(this, 'AssetsTable', {
partitionKey: { name: 'assetId', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'expiresAt',
pointInTimeRecoverySpecification: {
pointInTimeRecoveryEnabled: !isPreview,
},
removalPolicy: cleanupPolicy,
});
The API CORS rule below can name this preview's exact CloudFront domain. S3 CORS cannot: making the bucket depend on the distribution while the distribution already depends on the bucket would create a CloudFormation cycle. The pre-signed POST policy—not CORS—enforces the exact bucket, key, type, size, and expiry.
The existing CloudFront distribution keeps the site as its default origin. Add one behaviour for processed output. Its origin path is /processed, so an external request for /assets/<id>.png reads processed/assets/<id>.png from the private asset bucket through Origin Access Control.
const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
defaultRootObject: 'index.html',
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
additionalBehaviors: {
'/assets/*': {
origin: origins.S3BucketOrigin.withOriginAccessControl(assetBucket, {
originPath: '/processed',
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
},
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' },
],
});
Create a user pool and attach it to the HTTP API. User sign-up is intentionally disabled: this preview application accepts users provisioned by its operator, rather than exposing an anonymous registration endpoint with no abuse controls.
const users = new cognito.UserPool(this, 'Users', {
selfSignUpEnabled: false,
signInAliases: { email: true },
standardAttributes: {
email: { required: true, mutable: false },
},
passwordPolicy: {
minLength: 14,
requireDigits: true,
requireLowercase: true,
requireUppercase: true,
requireSymbols: true,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cleanupPolicy,
});
const hostedUiDomain = users.addDomain('HostedUiDomain', {
cognitoDomain: {
domainPrefix: `asset-preview-${pr}-${acct ?? 'local'}-${reg ?? 'local'}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63),
},
});
const webClient = users.addClient('WebClient', {
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL],
callbackUrls: [`https://${distribution.domainName}/`],
logoutUrls: [`https://${distribution.domainName}/`],
defaultRedirectUri: `https://${distribution.domainName}/`,
},
preventUserExistenceErrors: true,
});
const userAuthorizer = new authorizers.HttpUserPoolAuthorizer(
'UserAuthorizer',
users,
{ userPoolClients: [webClient] },
);
For a browser application, use the hosted UI at hostedUiDomain.baseUrl() with an OIDC authorization-code flow and PKCE, then send its ID token as Authorization: Bearer <token>. The client must never contain AWS access keys. API Gateway verifies the token before invoking either asset Lambda.
Replace the demo job producer #
Delete the SubmitJobFunction and its POST /jobs route from Day Four. S3 now sends actual uploads to the existing queue.
The create-upload Lambda returns a signed form. The get-asset Lambda lets the page poll state, but only after it confirms that the JWT subject owns the requested asset.
const createUpload = new lambdaNodejs.NodejsFunction(this, 'CreateUploadFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
entry: join(__dirname, '../lambda/create-upload/index.ts'),
handler: 'handler',
timeout: cdk.Duration.seconds(5),
memorySize: 256,
environment: {
ASSET_BUCKET_NAME: assetBucket.bucketName,
ASSETS_TABLE_NAME: assets.tableName,
},
});
const getAsset = new lambdaNodejs.NodejsFunction(this, 'GetAssetFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
entry: join(__dirname, '../lambda/get-asset/index.ts'),
handler: 'handler',
timeout: cdk.Duration.seconds(5),
memorySize: 256,
environment: {
ASSETS_TABLE_NAME: assets.tableName,
ASSET_BASE_URL: `https://${distribution.domainName}/assets`,
},
});
assetBucket.grantPut(createUpload);
assets.grantReadWriteData(createUpload);
assets.grantReadData(getAsset);
// Replace the permissive tutorial CORS configuration from Day Four.
const api = new apigwv2.HttpApi(this, 'PreviewApi', {
corsPreflight: {
allowHeaders: ['authorization', 'content-type'],
allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST],
allowOrigins: [`https://${distribution.domainName}`],
},
});
api.addRoutes({
path: '/assets',
methods: [apigwv2.HttpMethod.POST],
integration: new integrations.HttpLambdaIntegration('CreateUploadIntegration', createUpload),
authorizer: userAuthorizer,
});
api.addRoutes({
path: '/assets/{assetId}',
methods: [apigwv2.HttpMethod.GET],
integration: new integrations.HttpLambdaIntegration('GetAssetIntegration', getAsset),
authorizer: userAuthorizer,
});
new cdk.CfnOutput(this, 'AssetApiBaseUrl', { value: api.apiEndpoint });
new cdk.CfnOutput(this, 'UserPoolId', { value: users.userPoolId });
new cdk.CfnOutput(this, 'UserPoolClientId', { value: webClient.userPoolClientId });
new cdk.CfnOutput(this, 'UserPoolHostedUiUrl', { value: hostedUiDomain.baseUrl() });
new cdk.CfnOutput(this, 'UserPoolIssuer', {
value: `https://cognito-idp.${this.region}.amazonaws.com/${users.userPoolId}`,
});
The S3 event dispatches processing. This avoids a gap where the application says a file is queued but the actual upload did not finish.
assetBucket.addEventNotification(
s3.EventType.OBJECT_CREATED,
new s3n.SqsDestination(assetsQueue),
{ prefix: 'uploads/originals/' },
);
Constrained upload form #
lambda/create-upload/index.ts
import { randomUUID } from 'node:crypto';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const s3 = new S3Client({});
const acceptedTypes = new Map<string, string>([
['image/jpeg', 'jpg'],
['image/png', 'png'],
]);
const maxBytes = 10 * 1024 * 1024;
export const handler = async (event: any) => {
const ownerId = event?.requestContext?.authorizer?.jwt?.claims?.sub;
if (!ownerId) return reply(401, { error: 'Authentication is required' });
let request: { contentType?: string };
try {
request = JSON.parse(event.body ?? '{}');
} catch {
return reply(400, { error: 'Invalid JSON body' });
}
const contentType = request.contentType ?? '';
const extension = acceptedTypes.get(contentType);
if (!extension) {
return reply(400, { error: 'Only image/png and image/jpeg are accepted' });
}
const assetId = randomUUID();
const sourceKey = `uploads/originals/${assetId}.${extension}`;
const now = new Date();
const expiresAt = Math.floor(now.getTime() / 1_000) + 24 * 60 * 60;
await db.send(new PutCommand({
TableName: requiredEnv('ASSETS_TABLE_NAME'),
Item: {
assetId,
ownerId,
status: 'uploading',
contentType,
sourceKey,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt,
},
ConditionExpression: 'attribute_not_exists(assetId)',
}));
const upload = await createPresignedPost(s3, {
Bucket: requiredEnv('ASSET_BUCKET_NAME'),
Key: sourceKey,
Expires: 300,
Fields: { 'Content-Type': contentType },
Conditions: [
['content-length-range', 1, maxBytes],
['eq', '$Content-Type', contentType],
['eq', '$key', sourceKey],
],
});
return reply(201, { asset: { assetId, status: 'uploading' }, upload });
};
const reply = (statusCode: number, body: unknown) => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const requiredEnv = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
};
The API validates the request's declared type and the signed policy enforces it at S3. The worker still examines file signatures before publishing anything; Content-Type alone is metadata supplied by the client.
Validation and promotion #
Replace the Day Four worker with a bundled Lambda. It receives an SQS record containing an S3 event, checks that the object corresponds to an application-created record, verifies its magic bytes, and copies it into the processed prefix.
lambda/asset-worker/index.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const s3 = new S3Client({});
export const handler = async (event: any) => {
const batchItemFailures = [];
for (const message of event.Records ?? []) {
try {
const s3Event = JSON.parse(message.body);
for (const record of s3Event.Records ?? []) await processObject(record);
} catch (error) {
console.error('Asset processing failed', { messageId: message.messageId, error });
batchItemFailures.push({ itemIdentifier: message.messageId });
}
}
return { batchItemFailures };
};
async function processObject(record: any) {
const sourceKey = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
const match = sourceKey.match(/^uploads\/originals\/([0-9a-f-]+)\.(png|jpg)$/);
const assetId = match?.[1];
if (!assetId) throw new Error(`Unexpected object key: ${sourceKey}`);
const { Item: asset } = await db.send(new GetCommand({
TableName: requiredEnv('ASSETS_TABLE_NAME'),
Key: { assetId },
ConsistentRead: true,
}));
if (!asset || asset.sourceKey !== sourceKey) throw new Error('Upload has no matching asset record');
if (asset.status === 'ready' || asset.status === 'rejected') return; // S3 and SQS can redeliver events.
await update(assetId, 'SET #status = :status, updatedAt = :now', {
':status': 'processing', ':now': new Date().toISOString(),
});
const sample = await s3.send(new GetObjectCommand({
Bucket: requiredEnv('ASSET_BUCKET_NAME'),
Key: sourceKey,
Range: 'bytes=0-15',
}));
const bytes = new Uint8Array(await sample.Body!.transformToByteArray());
if (!hasExpectedSignature(bytes, asset.contentType)) {
await s3.send(new DeleteObjectCommand({
Bucket: requiredEnv('ASSET_BUCKET_NAME'),
Key: sourceKey,
}));
await update(assetId, 'SET #status = :status, #error = :error, updatedAt = :now', {
':status': 'rejected', ':error': 'File bytes do not match the requested image type', ':now': new Date().toISOString(),
});
return;
}
const extension = asset.contentType === 'image/png' ? 'png' : 'jpg';
const outputKey = `processed/assets/${assetId}.${extension}`;
await s3.send(new CopyObjectCommand({
Bucket: requiredEnv('ASSET_BUCKET_NAME'),
CopySource: `${requiredEnv('ASSET_BUCKET_NAME')}/${encodeURIComponent(sourceKey)}`,
Key: outputKey,
ContentType: asset.contentType,
MetadataDirective: 'REPLACE',
}));
await update(assetId, 'SET #status = :status, outputKey = :outputKey, updatedAt = :now REMOVE expiresAt', {
':status': 'ready', ':outputKey': outputKey, ':now': new Date().toISOString(),
});
}
function hasExpectedSignature(bytes: Uint8Array, contentType: string) {
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const jpeg = [0xff, 0xd8, 0xff];
const expected = contentType === 'image/png' ? png : jpeg;
return expected.every((byte, index) => bytes[index] === byte);
}
function update(assetId: string, expression: string, values: Record<string, unknown>) {
const names: Record<string, string> = { '#status': 'status' };
if (expression.includes('#error')) names['#error'] = 'error';
return db.send(new UpdateCommand({
TableName: requiredEnv('ASSETS_TABLE_NAME'),
Key: { assetId },
UpdateExpression: expression,
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ConditionExpression: 'attribute_exists(assetId)',
}));
}
const requiredEnv = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
};
Connect the worker to the asset bucket and table, then retain the partial-batch behaviour from Day Four:
const worker = new lambdaNodejs.NodejsFunction(this, 'AssetWorkerFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
entry: join(__dirname, '../lambda/asset-worker/index.ts'),
handler: 'handler',
timeout: cdk.Duration.seconds(30),
memorySize: 512,
environment: {
ASSET_BUCKET_NAME: assetBucket.bucketName,
ASSETS_TABLE_NAME: assets.tableName,
},
});
assetBucket.grantReadWrite(worker);
assets.grantReadWriteData(worker);
worker.addEventSource(new eventsources.SqsEventSource(assetsQueue, {
batchSize: 5,
reportBatchItemFailures: true,
}));
The worker promotes a validated original without changing its pixels. That is intentional: validation is already a useful asynchronous workload.
Reading the asset #
lambda/get-asset/index.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler = async (event: any) => {
const assetId = event?.pathParameters?.assetId;
const ownerId = event?.requestContext?.authorizer?.jwt?.claims?.sub;
if (!assetId || !ownerId) return reply(400, { error: 'assetId is required' });
const { Item: asset } = await db.send(new GetCommand({
TableName: requiredEnv('ASSETS_TABLE_NAME'),
Key: { assetId },
ConsistentRead: true,
}));
if (!asset || asset.ownerId !== ownerId) return reply(404, { error: 'Asset not found' });
const result: Record<string, unknown> = {
assetId: asset.assetId,
status: asset.status,
contentType: asset.contentType,
createdAt: asset.createdAt,
};
if (asset.status === 'ready') {
const extension = asset.contentType === 'image/png' ? 'png' : 'jpg';
result.url = `${requiredEnv('ASSET_BASE_URL')}/${asset.assetId}.${extension}`;
}
if (asset.status === 'rejected') result.error = asset.error;
return reply(200, { asset: result });
};
const reply = (statusCode: number, body: unknown) => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const requiredEnv = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
};
Returning 404 for both a nonexistent asset and an asset owned by someone else avoids confirming that another user's ID exists.
Configure sign-in and the page #
Extend the existing post-deploy step that writes site/config.js. It must take its values from the deployed stack, never from a checked-in preview configuration.
STACK="Stack-PR${PR_NUMBER}"
API_BASE_URL=$(jq -r --arg stack "$STACK" '.[$stack].AssetApiBaseUrl' cdk-outputs.json)
USER_POOL_ID=$(jq -r --arg stack "$STACK" '.[$stack].UserPoolId' cdk-outputs.json)
USER_POOL_CLIENT_ID=$(jq -r --arg stack "$STACK" '.[$stack].UserPoolClientId' cdk-outputs.json)
USER_POOL_ISSUER=$(jq -r --arg stack "$STACK" '.[$stack].UserPoolIssuer' cdk-outputs.json)
USER_POOL_HOSTED_UI_URL=$(jq -r --arg stack "$STACK" '.[$stack].UserPoolHostedUiUrl' cdk-outputs.json)
for value in "$API_BASE_URL" "$USER_POOL_ID" "$USER_POOL_CLIENT_ID" "$USER_POOL_ISSUER" "$USER_POOL_HOSTED_UI_URL"; do
test -n "$value" && test "$value" != "null"
done
cat > site/config.js <<EOF
window.__PREVIEW_CONFIG__ = {
apiBaseUrl: '${API_BASE_URL%/}',
userPoolId: '${USER_POOL_ID}',
userPoolClientId: '${USER_POOL_CLIENT_ID}',
userPoolIssuer: '${USER_POOL_ISSUER}',
userPoolHostedUiUrl: '${USER_POOL_HOSTED_UI_URL}'
};
EOF
The preview page implements authorization-code flow with PKCE: it stores only the verifier and state during the redirect, checks the returned state, exchanges the code, and keeps the ID token in browser memory. It never stores AWS credentials or hand-crafts JWTs.
The page #
The browser sends the selected file directly to the signed form, then polls the authenticated status endpoint. The API URL and Cognito client configuration belong in generated site/config.js.
const requestUpload = async (file, token) => {
const response = await fetch(`${apiBaseUrl}/assets`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify({ contentType: file.type }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
const form = new FormData();
for (const [name, value] of Object.entries(data.upload.fields)) form.append(name, value);
form.append('file', file);
const uploadResponse = await fetch(data.upload.url, { method: 'POST', body: form });
if (!uploadResponse.ok) throw new Error(`S3 upload failed: ${uploadResponse.status}`);
return data.asset.assetId;
};
const waitForAsset = async (assetId, token) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000));
const response = await fetch(`${apiBaseUrl}/assets/${encodeURIComponent(assetId)}`, {
headers: { authorization: `Bearer ${token}` },
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
if (data.asset.status === 'ready' || data.asset.status === 'rejected') return data.asset;
}
throw new Error('The asset did not finish processing in time');
};
Do not mark an upload complete based solely on the browser's POST response. The asset is usable only when the authenticated status API returns ready and supplies its CloudFront URL.
The vertical slice #
After the GitHub Actions deploy succeeds, obtain the preview stack outputs. Replace the PR number and region with the values for your deployment.
STACK=Stack-PR123
REGION=us-east-1
POOL_ID=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='UserPoolId'].OutputValue | [0]" \
--output text)
TABLE=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='AssetsTableName'].OutputValue | [0]" \
--output text)
QUEUE=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='AssetsQueueName'].OutputValue | [0]" \
--output text)
Self-sign-up is disabled, so provision a test user once for the preview. Use a password that satisfies the user pool policy: at least 14 characters, with upper- and lowercase letters, a number, and a symbol.
EMAIL=you@example.com
read -rs PASSWORD
aws cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username "$EMAIL" \
--user-attributes Name=email,Value="$EMAIL" Name=email_verified,Value=true \
--message-action SUPPRESS \
--region "$REGION"
aws cognito-idp admin-set-user-password \
--user-pool-id "$POOL_ID" \
--username "$EMAIL" \
--password "$PASSWORD" \
--permanent \
--region "$REGION"
Open the Preview URL from the pull-request comment, sign in with that user, and upload a small PNG or JPEG. The page should show these state transitions:
uploading -> processing -> ready
For an invalid file masquerading as an image, the worker records:
uploading -> processing -> rejected
Inspect the durable asset record after an upload:
aws dynamodb scan \
--table-name "$TABLE" \
--region "$REGION" \
--max-items 10
For a successful upload, expect status: ready and an outputKey. For a rejected upload, expect status: rejected and an error.
Tail the worker logs. Resolve the log group first; do not type the <function-name> placeholder literally.
WORKER_LOG_GROUP=$(aws logs describe-log-groups \
--region "$REGION" \
--query "logGroups[?contains(logGroupName, 'AssetWorkerFunction')].logGroupName | [0]" \
--output text)
echo "$WORKER_LOG_GROUP"
aws logs tail "$WORKER_LOG_GROUP" --region "$REGION" --since 10m
If the upload is still processing or the worker has failed, inspect the queue depth:
QUEUE_URL=$(aws sqs get-queue-url \
--queue-name "$QUEUE" \
--region "$REGION" \
--query QueueUrl \
--output text)
aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--region "$REGION" \
--attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible
Finally, verify the AWS boundaries:
- the browser has no AWS credentials and receives only a five-minute form for one S3 key
- the asset bucket has Block Public Access enabled
- the original object is under
uploads/originals/and has no CloudFront route - only a processed object is available at
/assets/<assetId>.<extension> - the worker logs include the asset ID, and repeated S3/SQS delivery does not create a second asset record
- a failed worker invocation follows the existing retry and DLQ policy
Operating model #
- PR stacks use
DESTROYand delete their objects when the PR closes; a production stack usesRETAINand enables DynamoDB point-in-time recovery. - The upload policy and API authorizer are narrowly scoped to the one operation and preview origin they support. S3 CORS permits CloudFront subdomains so the stack avoids a bucket/distribution dependency cycle; it is not the authorization boundary.
- DynamoDB TTL cleans up abandoned upload records eventually. The S3 lifecycle rule aborts incomplete multipart uploads and trims only non-current object versions; it does not delete accepted assets behind the application's back.
- S3 notifications and SQS are at-least-once. The worker's output key is deterministic and
readyassets are safe to observe more than once. Any future non-idempotent transformation must preserve that property. - The output path is public through CloudFront. If the product later requires per-user private downloads, add a viewer authorization design such as CloudFront signed cookies before treating output URLs as confidential.
Well-Architected Framework #
- Security: Cognito authenticates API calls, API Gateway verifies JWTs before Lambda runs, S3 is private and encrypted, and the pre-signed policy constrains direct upload.
- Operational Excellence: asset IDs connect the browser, API, S3 event, SQS delivery, worker logs, DynamoDB record, and CloudFront output.
- Reliability: S3 triggers the queue only after an object is created; SQS retries worker failures and the DLQ retains poison events for investigation.
- Performance Efficiency: browsers upload directly to S3, while Lambda performs only short control-plane and validation work.
- Cost Optimization: S3, DynamoDB on-demand, SQS, and Lambda scale with actual uploads; short-lived preview stacks remove their own resources.
- Sustainability: direct upload avoids an extra compute hop, version lifecycle rules limit unnecessary storage, and ephemeral previews avoid idle infrastructure.