Secure S3 Image Uploads and Viewing with Presigned URLs and Cognito
A deep dive into securely managing user-specific image uploads to S3 using presigned URLs and authorizing access for viewing with AWS Cognito.

Managing user-uploaded images in a web application requires a robust and secure mechanism. Users should only be able to upload to designated locations and view images they are authorized to see. In our this example, we leverage S3 presigned URLs for secure uploads and AWS Cognito for fine-grained access control when viewing images. This post explores how these AWS services work together to create a secure image management workflow.
The Challenge: Secure User-Specific Image Storage
When users upload photos, we need to ensure:
- Uploads are authenticated and authorized.
- Files are stored in a way that isolates one user's photos from another's.
- Users can only view their own photos.
Our solution uses AWS S3 for storage, AWS Lambda and AppSync for backend logic, and AWS Cognito for user authentication and authorization.
Uploading Images: S3 Presigned URLs
Instead of routing file uploads through our backend server, which can be resource-intensive, we use S3 presigned URLs. This allows the client application to upload files directly to S3 in a secure manner.
Backend: Generating the Presigned URL
An AppSync mutation, getUploadUrl
, backed by a Lambda function, is responsible for generating the presigned URL.
Key aspects of the Lambda function (infra/src/lambdas/appsync/getUploadUrl.ts
):
- Authentication: It first verifies that the request comes from an authenticated Cognito user.
- S3 Key Construction: It constructs a unique S3 key for the photo, crucially incorporating the user's Cognito Identity ID. This ensures that photos are stored in a user-specific "folder" within the S3 bucket. The inclusion of the
COGNITO_IDENTITY_ID
in the prefix will allow us to create a specific IAM Role to ensure that each user only has access to their own photos.<COGNITO_IDENTITY_ID>/<COLLECTION_ID>/<UNIQUE_FILENAME>
- Presigned URL Generation: It uses the AWS SDK (
@aws-sdk/s3-request-presigner
) to generate a short-lived presigned URL for aPUT
operation on the constructed S3 key.
Here's a simplified snippet of the Lambda logic:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AppSyncResolverEvent, AppSyncIdentityCognito } from "aws-lambda";
import { randomUUID } from "crypto";
const s3Client = new S3Client({ region: process.env.REGION });
const bucketName = process.env.BUCKET_NAME;
interface GetUploadUrlArgs {
collectionId: string;
filename: string;
contentType: string;
identityPoolId: string;
}
interface UploadDetails {
url: string;
s3Key: string;
}
export const handler = async (
event: AppSyncResolverEvent<GetUploadUrlArgs>,
): Promise<UploadDetails | null> => {
const { collectionId, filename, contentType, identityPoolId } =
event.arguments;
const identity = event.identity as AppSyncIdentityCognito;
if (!identity || !identity.sub) {
throw new Error("Not authorized.");
}
if (!bucketName) {
throw new Error("Internal server error: Bucket not configured.");
}
const uniqueFilename = `${randomUUID()}-${filename}`;
const s3Key = `${identityPoolId}/${collectionId}/${uniqueFilename}`;
const command = new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
ContentType: contentType,
});
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 });
return { url: signedUrl, s3Key: s3Key };
};
The GraphQL mutation in exposes this:
type Mutation {
getUploadUrl(
collectionId: ID!
filename: String!
contentType: String!
identityPoolId: String!
): UploadDetails @aws_cognito_user_pools
}
type UploadDetails {
url: AWSURL!
s3Key: String!
}
Frontend: Using the Presigned URL
A frontend component handles the client-side upload:
- Authentication: The user must be logged in. The component fetches the user's Cognito
identityId
usingfetchAuthSession
from Amplify. - Request Presigned URL: It calls the
getUploadUrl
AppSync mutation, passing theidentityId
and file details. - Direct S3 Upload: It uses the received presigned URL to
PUT
the file directly to S3 using thefetch
API. - Metadata Update: After a successful S3 upload, it calls another AppSync mutation (
addPhoto
) to save the photo's metadata (like thes3Key
) in DynamoDB.
Simplified frontend logic:
import { generateClient } from "aws-amplify/api";
import { fetchAuthSession } from "aws-amplify/auth";
import { getUploadUrl, addPhoto } from "@/graphql/mutations"; // Your GraphQL mutations
const client = generateClient();
const session = await fetchAuthSession();
const identityId = session.identityId;
if (!identityId) throw new Error("User identity not found.");
// Request presigned URL
const urlResult = await client.graphql({
query: getUploadUrl,
variables: {
collectionId,
filename: file.name,
contentType: file.type,
identityPoolId: identityId,
},
});
const { url: presignedUrl, s3Key } = urlResult.data?.getUploadUrl;
if (!presignedUrl || !s3Key) throw new Error("Failed to get upload details.");
// Upload file to S3
await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
// Add photo metadata to database
await client.graphql({
query: addPhoto,
variables: { collectionId, s3Key },
});
The S3 bucket is configured with a CORS policy in the CDK to allow these PUT
requests from the web application's domain. This allows the client's browser to make a direct PUT
request to S3, which is a cross-origin request.
import { Bucket, BlockPublicAccess, HttpMethods } from "aws-cdk-lib/aws-s3";
this.photosBucket = new Bucket(this, "PhotosBucket", {
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
// ... other properties like removalPolicy, lifecycleRules ...
cors: [
{
allowedMethods: [
HttpMethods.GET,
HttpMethods.PUT,
HttpMethods.POST,
HttpMethods.HEAD,
],
allowedOrigins: [
`https://${props.domainName}`,
`https://www.${props.domainName}`,
"http://localhost:5173",
],
allowedHeaders: ["*", "Content-Type"],
exposedHeaders: ["ETag"],
},
],
});
Viewing Images: Cognito-Authorized S3 Access
For viewing images, a common alternative to presigned URLs is to leverage the authenticated user's IAM role, derived from their Cognito session, to access S3 objects directly. While presigned URLs are excellent for uploads (as they grant temporary, specific write access without exposing long-term credentials to the client for that operation), using them for every image view can introduce complexity and overhead.
Why Direct S3 Access with Cognito IAM Roles for Viewing?
The primary motivation for using direct S3 access via Cognito IAM roles for viewing, instead of generating presigned GET URLs for each image, is to simplify the client-side workflow and reduce back-and-forth requests.
Consider the alternative with presigned GET URLs for viewing:
- Client fetches photo metadata (which includes the S3 key) from the backend (e.g., DynamoDB via AppSync).
- For each photo to be displayed, the client makes another backend call to generate a presigned GET URL.
- The client then uses this presigned URL to fetch the image from S3.
This involves multiple round trips per image, increasing latency and backend load, especially for galleries.
Our chosen approach—direct S3 access using IAM policy variables (\${cognito-identity.amazonaws.com:sub}
)—streamlines this:
- Reduced API Calls: Once the user is authenticated and photo metadata (S3 keys) is fetched, the client can directly request images from S3. There's no need for an intermediate step to generate a presigned URL for each view. This greatly simplifies the process of requesting objects from S3.
- Simplified Client Logic: The frontend constructs standard S3 URLs (e.g.,
https://[bucket-name].s3.[region].amazonaws.com/[s3Key]
). These can be used directly in<img>
tags. If using AWS Amplify or the AWS SDK for JavaScript, these libraries, when configured with the user's Cognito session, automatically handle using the temporary credentials to authorize the S3 GET requests. - Effective Browser Caching: Standard S3 URLs are more amenable to browser caching compared to temporary presigned URLs, which can have short expiry times.
- Consistent and Robust Authorization: The IAM role associated with the authenticated Cognito identity provides a consistent set of permissions for the duration of the user's session. This role, as we'll see, is precisely scoped to allow access only to the user's own objects, maintaining strong user level separation of objects in the bucket.
The trade-off is that the client application needs to be configured to use AWS credentials (obtained via Cognito) for S3 requests, typically handled by a library like AWS Amplify. However, this is often a one-time setup.
Now, let's look at how this direct access is implemented securely.
To ensure users can only view their own images we leverage Cognito Identity Pools, IAM roles, and the structure of the prefixes we created when uploading the images.
Core Cognito Configuration: User Pool, Identity Pool, and IAM Roles
The foundation of our authorization mechanism is AWS Cognito. In our Cognito Stack, we define several key resources:
- User Pool: Manages user identities, sign-up, and sign-in processes.
- User Pool Client: An entity that allows our frontend application to interact with the User Pool.
- Identity Pool: Enables users (both authenticated via the User Pool and potentially unauthenticated guests) to obtain temporary AWS credentials.
- IAM Roles (Authenticated and Unauthenticated): These roles are assumed by users through the Identity Pool. The
AuthenticatedRole
is particularly important here, as it will be granted the specific permissions needed to access S3 resources.
Here's a simplified look at their definition:
import {
UserPool,
UserPoolClient,
CfnIdentityPool,
CfnIdentityPoolRoleAttachment,
// ... other Cognito and IAM imports
} from "aws-cdk-lib/aws-cognito";
import { Role, FederatedPrincipal } from "aws-cdk-lib/aws-iam";
// --- User Pool ---
const userPool = new UserPool(this, "AdminUserPool", {
userPoolName: `${props.environment}-admin-pool`,
selfSignUpEnabled: true,
signInAliases: { email: true },
// ... other configurations like passwordPolicy, mfa, standardAttributes
});
// --- User Pool Client ---
const userPoolClient = new UserPoolClient(this, "AdminUserPoolClient", {
userPool: userPool,
// ... oAuth flows, scopes, callbackUrls, token validity settings
});
// --- Identity Pool ---
const identityPool = new CfnIdentityPool(this, "IdentityPool", {
identityPoolName: `${props.environment}-identity-pool`,
cognitoIdentityProviders: [
{
clientId: userPoolClient.userPoolClientId,
providerName: userPool.userPoolProviderName,
},
],
});
// --- IAM Roles ---
const authenticatedRole = new Role(this, "AuthenticatedRole", {
assumedBy: new FederatedPrincipal(
"cognito-identity.amazonaws.com",
{
StringEquals: { "cognito-identity.amazonaws.com:aud": identityPool.ref },
"ForAnyValue:StringLike": {
"cognito-identity.amazonaws.com:amr": "authenticated",
},
},
"sts:AssumeRoleWithWebIdentity",
),
description: "IAM role for authenticated users accessing images",
});
// --- Role Attachment ---
new CfnIdentityPoolRoleAttachment(this, "IdentityPoolRoleAttachment", {
identityPoolId: identityPool.ref,
roles: {
authenticated: authenticatedRole.roleArn,
// unauthenticated: unauthenticatedRole.roleArn, // If using unauthenticated access
},
});
This setup ensures that when a user signs in through the User Pool, they can obtain temporary AWS credentials via the Identity Pool, assuming the AuthenticatedRole
.
Backend: IAM Policy for Authenticated Users
In our CDK stack, we define an IAM policy for the AuthenticatedRole
(which was created in CognitoStack
and imported here). This policy grants s3:GetObject
permission, but only for objects under the user's own "folder" in S3. This is where the prefix we created when we uploaded the image is used.
It's important to note that the photosBucket
itself is configured with blockPublicAccess: BlockPublicAccess.BLOCK_ALL
(as seen in the CORS snippet earlier). Access control for viewing private user photos is therefore not managed by a broad S3 bucket policy that grants Cognito access directly on the bucket, but rather by this specific IAM policy attached to the Cognito AuthenticatedRole
.
// infra/src/stacks/photo-blog-stack.ts (Policy Attachment to AuthenticatedRole)
import { Policy, PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
// ... authenticatedRole is obtained or imported ...
// ... storage.photosBucket is the S3 bucket construct ...
authenticatedRole.attachInlinePolicy(
new Policy(this, "CognitoAuthUserS3Policy", {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ["s3:GetObject"],
resources: [
// Policy for objects within the user's folder
`${storage.photosBucket.bucketArn}/\${cognito-identity.amazonaws.com:sub}/*`,
],
}),
],
}),
);
The magic of multi-user isolation within the single photosBucket
lies in the \${cognito-identity.amazonaws.com:sub}
part of the resource ARN. Let's break this down:
- IAM Policy Variables:
\${cognito-identity.amazonaws.com:sub}
is an IAM policy variable. These variables act as placeholders that AWS replaces with actual values from the request context when the policy is evaluated. - Cognito Identity ID (
sub
): Specifically,cognito-identity.amazonaws.com:sub
resolves to the unique Cognito Identity ID of the user making the request. This ID is assigned when a user authenticates through your Cognito User Pool and then gets credentials from the associated Cognito Identity Pool. Each user receives a distinct Identity ID. - Dynamic Path Scoping: When this variable is used in the S3 resource path
${storage.photosBucket.bucketArn}/\${cognito-identity.amazonaws.com:sub}/*
, the policy effectively becomes specific to the currently authenticated user.- For User A (e.g., Identity ID
us-east-1:aaaa-aaaa
), the policy allows access tos3://your-photos-bucket/us-east-1:aaaa-aaaa/*
. - For User B (e.g., Identity ID
us-east-1:bbbb-bbbb
), the policy allows access tos3://your-photos-bucket/us-east-1:bbbb-bbbb/*
.
- For User A (e.g., Identity ID
How This Ensures Security and Isolation:
- Upload Pathing: As we saw, the
getUploadUrl
Lambda constructs the S3 key as<USER_COGNITO_IDENTITY_ID>/<COLLECTION_ID>/<FILENAME>
. This means each user's photos are automatically placed into a prefix (like a folder) named after their unique Cognito Identity ID. - Download/View Authorization: When User A attempts to view a photo, their request to S3 is signed with temporary credentials associated with their Cognito Identity ID. The IAM policy attached to their
AuthenticatedRole
is evaluated.- If User A tries to access
s3://your-photos-bucket/us-east-1:aaaa-aaaa/some-photo.jpg
, the\${cognito-identity.amazonaws.com:sub}
in the policy resolves tous-east-1:aaaa-aaaa
. The resource path in the policy matches the requested object's path, and access is granted. - If User A tries to access User B's photo at
s3://your-photos-bucket/us-east-1:bbbb-bbbb/other-photo.jpg
, their\${cognito-identity.amazonaws.com:sub}
still resolves tous-east-1:aaaa-aaaa
. The resource path in the policy (.../us-east-1:aaaa-aaaa/*
) does not match the requested object's path (.../us-east-1:bbbb-bbbb/...
), and access is denied.
- If User A tries to access
This elegant mechanism allows multiple users to store their private photos in the same S3 bucket, yet each user's photos are securely isolated from others, accessible only by the owner. The S3 bucket itself can remain private (with BlockPublicAccess.BLOCK_ALL
), and all access control is managed through IAM policies dynamically scoped by Cognito user identities.
Frontend: Displaying Images
When the frontend needs to display an image:
- It retrieves the photo's
s3Key
from the backend (e.g., via an AppSync query likelistMyCollections
). - It constructs the full S3 URL for the image (e.g.,
https://<bucket-name>.s3.<region>.amazonaws.com/<s3Key>
). - When an
<img src="S3_URL" />
tag makes a request, the browser (if the user is authenticated with Cognito and Amplify is configured for S3 access) will automatically use the temporary AWS credentials associated with the user's session. - S3 validates these credentials against the IAM policy. If the user is trying to access an S3 object under their
\${cognito-identity.amazonaws.com:sub}/
path, thes3:GetObject
action is allowed, and the image is served. Otherwise, access is denied.
Conclusion
By combining S3 presigned URLs for uploads and Cognito Identity Pool-based IAM authorization for downloads, we achieve a secure and scalable system for managing user-specific images. Presigned URLs offload the bandwidth of uploads directly to S3, while Cognito and IAM provide fine-grained access control, ensuring users can only access their own content. This architecture effectively separates concerns and leverages AWS managed services for a robust solution.