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 Storage Integration AssumeRole 🔑 IAM Role Trust Policy + S3 Permissions S3:GetObject 🪣 S3 Bucket Bucket Policy + IAM Policy COPY INTO table (data flows via external stage) Your Account AWS IAM AWS S3
💡 How it Works

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 ACCOUNTADMIN or SYSADMIN + CREATE INTEGRATION privilege
  • 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

01 CREATE IAM POLICY FOR S3 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.

snowflake-s3-policy.json
{
  "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.

terminal — aws cli
# 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"
    }
}
⚠️ Least Privilege

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.

02 CREATE IAM ROLE WITH PLACEHOLDER TRUST

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.

trust-policy-placeholder.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SnowflakeTrustPlaceholder",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
terminal — aws cli
# 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
03 CREATE STORAGE INTEGRATION IN SNOWFLAKE

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.

snowflake — sql worksheet
-- 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';
📝 STORAGE_ALLOWED_LOCATIONS

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.

04 RETRIEVE SNOWFLAKE IAM VALUES

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.

snowflake — sql worksheet
-- 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.

05 UPDATE IAM TRUST POLICY

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.

trust-policy-final.json
{
  "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..."
        }
      }
    }
  ]
}
terminal — aws cli
# 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..."
                }
            }
        }
    ]
}
⚠️ Always Include ExternalId

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.

06 CREATE SNOWFLAKE EXTERNAL STAGE

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.

snowflake — sql worksheet
-- 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:

snowflake — json / parquet variants
-- 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;
07 LOAD DATA WITH COPY INTO

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.

snowflake — load data
-- 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;
08 AUTOMATE WITH SNOWPIPE (OPTIONAL)

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.

snowflake — snowpipe setup
-- 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...
terminal — configure s3 event notification
# 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:

snowflake — verification queries
-- 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.