When loading data from Amazon S3 into Snowflake, the naive approach is to embed AWS access keys directly in your Snowflake stage. That approach has two major problems: credentials get rotated, and secrets in your data warehouse are a security audit nightmare.
The right way is a Snowflake Storage Integration — a trust relationship between Snowflake and your AWS account using IAM roles. No long-lived keys, no secrets to rotate. Snowflake assumes a role in your AWS account using its own managed identity, and AWS grants access based on IAM policy.
This guide walks through the entire setup end-to-end: creating the IAM policy, building the Storage Integration in Snowflake, wiring the trust relationship, creating an external stage, and verifying the connection with a real file load.
Snowflake creates an AWS IAM entity (a Snowflake-managed principal) per storage integration. You add that principal to your IAM role's trust policy. AWS then lets Snowflake assume that role temporarily — similar to how EC2 instance profiles work, but for a cloud data platform.
Prerequisites
- Snowflake account with
ACCOUNTADMINorSYSADMIN+CREATE INTEGRATIONprivilege - AWS account with permission to create IAM roles and S3 bucket policies
- An existing S3 bucket (or permission to create one)
- AWS CLI configured locally (for the IAM setup steps)
- SnowSQL CLI or Snowflake Web UI access
Step 1: Create an IAM Policy for S3 Access
First, we define what S3 actions Snowflake is allowed to perform. We grant GetObject and GetObjectVersion for reads, PutObject for writes (if you need to unload data back to S3), and ListBucket for stage browsing. Scope it to the specific bucket and prefix.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SnowflakeReadWrite",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
"s3:DeleteObject",
"s3:DeleteObjectVersion"
],
"Resource": "arn:aws:s3:::your-data-bucket/snowflake/*"
},
{
"Sid": "SnowflakeListBucket",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::your-data-bucket",
"Condition": {
"StringLike": {
"s3:prefix": ["snowflake/*"]
}
}
}
]
}
Create the policy using the AWS CLI. Replace your-data-bucket with your actual bucket name throughout this guide.
# Create the IAM policy $ aws iam create-policy \ --policy-name SnowflakeS3AccessPolicy \ --policy-document file://snowflake-s3-policy.json \ --description "Grants Snowflake read/write access to S3 data bucket" { "Policy": { "PolicyName": "SnowflakeS3AccessPolicy", "PolicyId": "ANPA...", "Arn": "arn:aws:iam::123456789012:policy/SnowflakeS3AccessPolicy", "CreateDate": "2026-09-25T10:00:00Z" } }
Scope the Resource ARN to the tightest prefix possible (e.g., snowflake/staging/*). Never use * on an entire bucket unless absolutely required. If you only need reads (COPY INTO Snowflake), omit s3:PutObject and s3:DeleteObject.
Step 2: Create an IAM Role (Placeholder Trust Policy)
We create an IAM role that Snowflake will assume. At this point we don't know the Snowflake-managed principal yet — we use a placeholder and update it in Step 5 after retrieving the details from Snowflake.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SnowflakeTrustPlaceholder",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:root"
},
"Action": "sts:AssumeRole"
}
]
}
# Create the IAM role $ aws iam create-role \ --role-name SnowflakeS3Role \ --assume-role-policy-document file://trust-policy-placeholder.json \ --description "IAM Role assumed by Snowflake Storage Integration" # Attach the S3 access policy to the role $ aws iam attach-role-policy \ --role-name SnowflakeS3Role \ --policy-arn arn:aws:iam::123456789012:policy/SnowflakeS3AccessPolicy # Note the Role ARN — you'll need this in Step 3 $ aws iam get-role --role-name SnowflakeS3Role \ --query 'Role.Arn' --output text arn:aws:iam::123456789012:role/SnowflakeS3Role
Step 3: Create the Storage Integration in Snowflake
Now switch to Snowflake. Run the following SQL as ACCOUNTADMIN. The STORAGE_AWS_ROLE_ARN is the role ARN you captured in the previous step.
-- Use ACCOUNTADMIN for storage integration creation USE ROLE ACCOUNTADMIN; -- Create the storage integration CREATE OR REPLACE STORAGE INTEGRATION s3_int_data_bucket TYPE = EXTERNAL_STAGE STORAGE_PROVIDER = 'S3' ENABLED = TRUE STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/SnowflakeS3Role' STORAGE_ALLOWED_LOCATIONS = ('s3://your-data-bucket/snowflake/'); -- Verify it was created SHOW INTEGRATIONS LIKE 's3_int_data_bucket';
You can specify multiple allowed S3 paths as a comma-separated list, e.g. ('s3://bucket-a/path/', 's3://bucket-b/'). Snowflake will only permit stage creation within these prefixes. Use the most specific path you need.
Step 4: Retrieve the Snowflake-Managed IAM Values
Snowflake creates two values you need to configure your AWS trust policy: the IAM User ARN that Snowflake uses to assume your role, and an External ID that prevents confused deputy attacks.
-- Retrieve the IAM principal details DESC INTEGRATION s3_int_data_bucket;
Look for these two properties in the output:
| Property | Example Value | Use |
|---|---|---|
STORAGE_AWS_IAM_USER_ARN |
arn:aws:iam::012345678901:user/snow-s3-user... |
The Snowflake-managed IAM user that will assume your role. Goes in the trust policy Principal. |
STORAGE_AWS_EXTERNAL_ID |
ABC12345_SFCRole=2_xyz... |
A unique ID that prevents confused deputy attacks. Required in the trust policy Condition. |
Copy both values — you'll paste them into the IAM trust policy in the next step.
Step 5: Update the IAM Role Trust Policy
Replace the placeholder trust policy with the real Snowflake principal values. This is the key security handshake that locks the role to your specific Snowflake account.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SnowflakeTrust",
"Effect": "Allow",
"Principal": {
/* Replace with STORAGE_AWS_IAM_USER_ARN from DESC INTEGRATION */
"AWS": "arn:aws:iam::012345678901:user/snow-s3-user-abc123"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
/* Replace with STORAGE_AWS_EXTERNAL_ID from DESC INTEGRATION */
"sts:ExternalId": "ABC12345_SFCRole=2_xyz..."
}
}
}
]
}
# Update the role's trust policy with the real Snowflake values $ aws iam update-assume-role-policy \ --role-name SnowflakeS3Role \ --policy-document file://trust-policy-final.json # Verify the trust policy is updated correctly $ aws iam get-role \ --role-name SnowflakeS3Role \ --query 'Role.AssumeRolePolicyDocument' { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::012345678901:user/snow-s3-user-abc123" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "ABC12345_SFCRole=2_xyz..." } } } ] }
The sts:ExternalId condition is not optional. Without it, any Snowflake customer who knows your role ARN could theoretically assume it (confused deputy problem). Always bind the trust to the exact External ID from your integration.
Step 6: Create an External Stage in Snowflake
With the trust relationship established, create an external stage that points to your S3 path. The stage references the integration — no credentials in sight.
-- Switch to your target database and schema USE DATABASE analytics_db; USE SCHEMA raw; -- Create the external stage using the storage integration CREATE OR REPLACE STAGE s3_raw_stage STORAGE_INTEGRATION = s3_int_data_bucket URL = 's3://your-data-bucket/snowflake/raw/' FILE_FORMAT = ( TYPE = 'CSV' FIELD_DELIMITER = ',' SKIP_HEADER = 1 NULL_IF = ('', 'NULL') EMPTY_FIELD_AS_NULL = TRUE COMPRESSION = 'AUTO' ); -- List files in the stage to verify access LIST @s3_raw_stage;
For JSON or Parquet files, swap the file format accordingly:
-- JSON stage CREATE OR REPLACE FILE FORMAT json_format TYPE = 'JSON' STRIP_OUTER_ARRAY = TRUE COMPRESSION = 'AUTO'; -- Parquet stage CREATE OR REPLACE FILE FORMAT parquet_format TYPE = 'PARQUET' SNAPPY_COMPRESSION = TRUE; -- Reference a named format in a stage CREATE OR REPLACE STAGE s3_parquet_stage STORAGE_INTEGRATION = s3_int_data_bucket URL = 's3://your-data-bucket/snowflake/parquet/' FILE_FORMAT = parquet_format;
Step 7: Load Data with COPY INTO
With the stage working, load files into a Snowflake table using COPY INTO. This is the standard bulk-load mechanism for Snowflake.
-- Create target table CREATE OR REPLACE TABLE raw.events ( event_id VARCHAR, user_id VARCHAR, event_type VARCHAR, properties VARIANT, created_at TIMESTAMP_NTZ ); -- Load all CSV files from the stage COPY INTO raw.events FROM @s3_raw_stage PATTERN = '.*events_.*\.csv\.gz' ON_ERROR = 'CONTINUE' PURGE = FALSE; -- Check load history SELECT * FROM information_schema.load_history WHERE schema_name = 'RAW' AND table_name = 'EVENTS' ORDER BY last_load_time DESC LIMIT 10;
Step 8: Automate Continuous Loads with Snowpipe
For continuous ingestion as files land in S3, Snowpipe uses an SQS event notification to trigger micro-batch loads automatically — no scheduler needed.
-- Create the Snowpipe that references our stage CREATE OR REPLACE PIPE raw.events_pipe AUTO_INGEST = TRUE AS COPY INTO raw.events FROM @s3_raw_stage FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1); -- Get the SQS ARN to configure S3 event notifications SHOW PIPES LIKE 'events_pipe'; -- Look for notification_channel column — it's an SQS ARN like: -- arn:aws:sqs:us-east-1:012345678901:sf-snowpipe-ABCDEF...
# Add S3 event notification to trigger Snowpipe SQS queue # Replace the SQS ARN with the one from SHOW PIPES $ aws s3api put-bucket-notification-configuration \ --bucket your-data-bucket \ --notification-configuration '{ "QueueConfigurations": [{ "QueueArn": "arn:aws:sqs:us-east-1:012345678901:sf-snowpipe-ABCDEF", "Events": ["s3:ObjectCreated:*"], "Filter": { "Key": { "FilterRules": [ {"Name": "prefix", "Value": "snowflake/raw/"}, {"Name": "suffix", "Value": ".csv.gz"} ] } } }] }'
Verification Checklist
Run these checks to confirm everything is wired correctly before going to production:
-- 1. Check integration status SHOW INTEGRATIONS LIKE 's3_int_data_bucket'; -- enabled column should be: true -- 2. List stage contents (should show S3 files) LIST @analytics_db.raw.s3_raw_stage; -- 3. Validate file format without loading COPY INTO raw.events FROM @s3_raw_stage VALIDATION_MODE = 'RETURN_10_ROWS'; -- 4. Check copy history for errors SELECT file_name, status, rows_loaded, errors_seen, first_error_message FROM information_schema.load_history WHERE table_name = 'EVENTS' ORDER BY last_load_time DESC;
Common Errors and Fixes
| Error | Root Cause | Fix |
|---|---|---|
AccessDenied on AssumeRole |
Trust policy Principal or ExternalId mismatch | Re-run DESC INTEGRATION and copy the exact values. Re-apply trust policy. |
S3 403 Forbidden |
IAM policy doesn't cover the S3 path used in the stage URL | Ensure the IAM policy Resource ARN matches the stage URL prefix exactly. |
Stage URL not in allowed locations |
Stage URL is outside STORAGE_ALLOWED_LOCATIONS |
Update integration with ALTER INTEGRATION ... SET STORAGE_ALLOWED_LOCATIONS. |
LIST returns empty |
No files in the path, or S3 prefix mismatch | Verify files exist at the exact S3 path with aws s3 ls s3://your-bucket/snowflake/raw/. |
Snowpipe not triggering |
SQS ARN in S3 notification config doesn't match pipe's channel | Re-run SHOW PIPES, copy the notification_channel ARN, update S3 notification. |
Conclusion
You've set up a production-grade, credential-free Snowflake-to-S3 integration. The key concepts to take away:
- No long-lived credentials — Snowflake assumes your IAM role using STS temporary tokens
- External ID prevents confused deputy — binds the trust to your specific Snowflake account
- STORAGE_ALLOWED_LOCATIONS scopes access — Snowflake enforces the path boundary even if IAM is broader
- Snowpipe enables event-driven ingestion — zero-latency loads as files land in S3
- VALIDATION_MODE lets you test before committing — always use it on new pipelines
For multi-environment setups, create one storage integration per environment (dev/staging/prod) pointing to separate S3 prefixes. This keeps IAM trust boundaries clean and makes least-privilege enforcement straightforward.