Building Workload Identity Federation with Snowflake × Terraform × GitHub Actions

Building Workload Identity Federation with Snowflake × Terraform × GitHub Actions

2025.12.10

This page has been translated by machine translation. View original

Introduction

I'm kasama from the Data Business Department.
This time, I built an authentication infrastructure using Workload Identity Federation (WIF) with Snowflake × Terraform.

While traditional Key Pair authentication required managing a private key, WIF enables secure authentication without private keys. By combining GitHub Actions with AWS IAM Roles, we can implement safe operations using only temporary credentials.

Prerequisites

As a prerequisite, this article discusses the open-source version of Terraform, not HCP Terraform (cloud version).

I'll implement based on the architecture described in the following blog. The implementation creates modules by user type and calls them from main.tf for dev/prd environments. Terraform init, plan, and apply commands are executed via GitHub Actions.

https://dev.classmethod.jp/articles/comparing-snowflake-terraform-directory-patterns/

Also, in the previous blog, I built a Storage Integration connecting Snowflake and AWS S3 using Terraform. This time, I'll apply Workload Identity Federation authentication to that configuration.

https://dev.classmethod.jp/articles/snowflake-terraform-s3-storage-integration/

snowflake-terraform-sample % tree
.
├── .github
│   └── workflows
│       └── dev-snowflake-terraform-cicd.yml
├── cfn
│   └── create_state_gha_resources.yml
├── environments
│   └── dev
│       ├── backend.tf
│       ├── main.tf
│       ├── variable.tf
│       └── versions.tf
└── modules
    └── data_engineer
        ├── main.tf
        ├── outputs.tf
        ├── variables.tf
        └── versions.tf

I'm using Snowflake Provider version 2.11.0 for this implementation.

Traditional Authentication Method (Key Pair Authentication)

First, let me explain the traditional Key Pair authentication.

Key Pair authentication uses RSA public/private key pairs to authenticate with Snowflake. We registered the public key with a Snowflake user and stored the private key in GitHub Secrets to pass to the Terraform Provider for authentication.

Key Pair Authentication Example
provider "snowflake" {
  role              = "SYSADMIN"
  organization_name = var.snowflake_organization_name
  account_name      = var.snowflake_account_name
  user              = "TERRAFORM_USER"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = var.snowflake_private_key  # private key required
  warehouse         = "TERRAFORM_WH"
}

This method requires regular rotation and secure storage of private keys, which creates operational overhead.

https://docs.snowflake.com/en/user-guide/key-pair-auth

What is Workload Identity Federation?

Workload Identity Federation (WIF) is a mechanism that authenticates to Snowflake using authentication features provided by each cloud provider. Specifically, it supports AWS IAM Roles, Microsoft Entra ID, and Google Cloud service accounts.

The major difference from traditional Key Pair authentication is that it uses only temporary credentials without private keys.

This authentication method was introduced in Snowflake Provider 2.10.0.

In this blog, I'll introduce an implementation example using AWS IAM Role for WIF.

https://docs.snowflake.com/user-guide/workload-identity-federation

https://registry.terraform.io/providers/snowflakedb/snowflake/2.10.0/docs/guides/authentication_methods#workload-identity-federation-wif-authenticator-flow

How WIF Authentication Works

The WIF authentication flow works as follows:

  1. GitHub Actions requests an OIDC token, which GitHub OIDC Provider issues
  2. GitHub Actions uses the OIDC token to AssumeRole an AWS IAM Role
  3. AWS STS issues temporary credentials and sets them as environment variables
  4. Terraform Snowflake Provider automatically detects authentication information from environment variables
  5. Terraform Provider sends AWS IAM Role attestation to Snowflake
  6. Snowflake verifies the attestation and confirms that it's a pre-authorized IAM Role

For detailed mechanisms, I've written based on my understanding from Snowflake's official documentation and the following article. There may be errors as I couldn't find clear descriptions in the official Snowflake documentation.
https://tech.layerx.co.jp/entry/snowflake-wif-for-aws-bash

Compared to Key Pair authentication, WIF has the following benefits:

  • No private key management: No need to generate, store, or rotate private keys
  • Automatic credential rotation: Temporary credentials are automatically invalidated after a short time
  • Enhanced security: Greatly reduced risk of credential leakage
  • Reduced operational overhead: No need to store private keys in GitHub Secrets, and configuration is simpler
  • Activity logging: Activity can be logged and monitored using cloud provider services like AWS CloudTrail

Prerequisites

To use WIF authentication, the following preparations are necessary.

Creating AWS IAM Role

First, create an IAM Role that GitHub Actions can assume. When creating it with CloudFormation, it looks like this:

cfn/create_state_gha_resources.yml
AWSTemplateFormatVersion: 2010-09-09
Description: |-
  Terraform S3 & GitHub Actions Setup - Creates S3 bucket for Terraform state
  and IAM role for GitHub Actions with comprehensive configuration

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Project Configuration"
        Parameters:
          - ProjectName
          - EnvName
      - Label:
          default: "GitHub Configuration"
        Parameters:
          - GitHubAccountName
          - GitHubRemoteRepoName1
          - GitHubRemoteRepoName2
          - GitHubOIDCProviderArn

Parameters:
  ProjectName:
    Type: String
    Description: Project name used for resource naming
  EnvName:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - stg
      - prd
    Description: Environment name
  GitHubAccountName:
    Type: String
    Description: GitHub account name of the repository
  GitHubRemoteRepoName1:
    Type: String
    Description: First GitHub repository name
  GitHubRemoteRepoName2:
    Type: String
    Description: Second GitHub repository name
  GitHubOIDCProviderArn:
    Type: String
    Description: ARN of the existing GitHub OIDC Provider

Resources:
  # S3 Bucket for Terraform state
  TerraformStateBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "${ProjectName}-${EnvName}-s3-terraform-state"
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      Tags:
        - Key: project
          Value: !Ref ProjectName
        - Key: environment
          Value: !Ref EnvName

  # IAM Role for GitHub Actions
  GitHubActionsRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-${EnvName}-iamrole-gha"
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: TrustGitHubActionsOIDCProvider
            Effect: Allow
            Principal:
              Federated: !Ref GitHubOIDCProviderArn
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringLike:
                token.actions.githubusercontent.com:sub:
                  - !Sub "repo:${GitHubAccountName}/${GitHubRemoteRepoName1}:*"
                  - !Sub "repo:${GitHubAccountName}/${GitHubRemoteRepoName2}:*"
      Policies:
        - PolicyName: !Sub "${ProjectName}-${EnvName}-iampolicy-gha"
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              # Terraform state S3 bucket permissions
              - Sid: TerraformS3StateBucketPolicy
                Effect: Allow
                Action:
                  - s3:ListBucket
                  - s3:GetBucketLocation
                  - s3:ListBucketMultipartUploads
                  - s3:ListBucketVersions
                  - s3:GetObject
                  - s3:GetObjectVersion
                  - s3:PutObject
                  - s3:DeleteObject
                Resource:
                  - !Sub "arn:aws:s3:::${TerraformStateBucket}"
                  - !Sub "arn:aws:s3:::${TerraformStateBucket}/*"
              # S3 bucket management for Terraform modules
              - Sid: ModuleS3BucketManagement
                Effect: Allow
                Action:
                  - s3:CreateBucket
                  - s3:DeleteBucket
                  - s3:PutBucketVersioning
                  - s3:GetBucketTagging
                  - s3:PutEncryptionConfiguration
                  - s3:GetEncryptionConfiguration
                  - s3:PutBucketTagging
                  - s3:PutBucketPolicy
                  - s3:GetBucketPolicy
                  - s3:DeleteBucketPolicy
                  - s3:GetBucketLocation
                  - s3:GetBucketAcl
                  - s3:GetBucketCORS
                  - s3:ListBucket
                  - s3:GetBucketWebsite
                  - s3:GetBucketVersioning
                  - s3:GetAccelerateConfiguration
                  - s3:GetBucketRequestPayment
                  - s3:GetBucketLogging
                  - s3:GetLifecycleConfiguration
                  - s3:GetReplicationConfiguration
                  - s3:GetBucketObjectLockConfiguration
                  - s3:PutBucketNotification
                  - s3:GetBucketNotification
                Resource:
                  - !Sub "arn:aws:s3:::${ProjectName}-${EnvName}-s3-*"
                  - !Sub "arn:aws:s3:::${ProjectName}-*"
              # IAM role management for Terraform modules
              - Sid: ModuleIamRoleManagement
                Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:GetRole
                  - iam:TagRole
                  - iam:PutRolePolicy
                  - iam:GetRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:ListRolePolicies
                  - iam:ListAttachedRolePolicies
                  - iam:ListInstanceProfilesForRole
                  - iam:UpdateAssumeRolePolicy
                  - iam:UntagRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${ProjectName}-${EnvName}-iamrole-*"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${ProjectName}-*"

The Trust Policy trusts the GitHub OIDC Provider and allows AssumeRole only from specific GitHub repositories. This IAM Role is just for "GitHub Actions to run Terraform" and doesn't need Snowflake-specific settings. This implementation hasn't changed from the Key-pair approach.

Create a CloudFormation stack from the AWS Management Console.

Creating Snowflake User

Next, create a WIF-compatible service account in Snowflake. Use the IAM Role ARN obtained above.

-- 1. Create Terraform service account (WIF-enabled)
USE ROLE SECURITYADMIN;

CREATE USER TERRAFORM_BLOG_USER
    TYPE = SERVICE
    WORKLOAD_IDENTITY = (
      TYPE = AWS
      ARN = 'arn:aws:iam::<aws_account_id>:role/<your-iam-role-name>'
    )
    COMMENT = 'Terraform automation service account with Workload Identity Federation';

-- 2. Grant minimum roles (only three)
-- SYSADMIN: Create Database/Warehouse/Stage
-- ACCOUNTADMIN: Create Storage Integration
-- SECURITYADMIN: Grant operations
USE ROLE ACCOUNTADMIN;

GRANT ROLE SYSADMIN TO USER TERRAFORM_BLOG_USER;
GRANT ROLE ACCOUNTADMIN TO USER TERRAFORM_BLOG_USER;
GRANT ROLE SECURITYADMIN TO USER TERRAFORM_BLOG_USER;

-- 3. Check configuration
SHOW USER WORKLOAD IDENTITY AUTHENTICATION METHODS FOR USER TERRAFORM_BLOG_USER;
DESC USER TERRAFORM_BLOG_USER;

-- 4. Create warehouse
USE ROLE SYSADMIN;

CREATE OR REPLACE WAREHOUSE TERRAFORM_BLOG_WH
    WAREHOUSE_SIZE = XSMALL
    AUTO_RESUME = TRUE
    AUTO_SUSPEND = 60
    INITIALLY_SUSPENDED = TRUE
    STATEMENT_TIMEOUT_IN_SECONDS = 300
    COMMENT = 'For terraform automation only';

Create with TYPE = SERVICE and specify the AWS IAM Role ARN in WORKLOAD_IDENTITY. This makes the user login-able only with WIF authentication.

Setting GitHub Secrets

Set the following values in GitHub Secrets. The previously required SNOWFLAKE_PRIVATE_KEY is no longer needed.

  • SNOWFLAKE_ORG_NAME - Snowflake organization name
  • SNOWFLAKE_ACCOUNT_NAME - Snowflake account name
  • AWS_ROLE_ARN - AWS IAM role ARN (created with CloudFormation)

Implementation

Let's look at the actual Terraform implementation.

environments/dev/versions.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    snowflake = {
      source  = "snowflakedb/snowflake"
      version = "~> 2.11.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "6.18.0"
    }
  }
}

# Snowflake provider with SYSADMIN role
provider "snowflake" {
  alias                      = "sysadmin"
  role                       = "SYSADMIN"
  organization_name          = var.snowflake_organization_name
  account_name               = var.snowflake_account_name
  user                       = "TERRAFORM_BLOG_USER"
  authenticator              = "WORKLOAD_IDENTITY"
  workload_identity_provider = "AWS"
  warehouse                  = "TERRAFORM_BLOG_WH"
  preview_features_enabled = [
    "snowflake_stage_resource"
  ]
}

# Snowflake provider with ACCOUNTADMIN role (for admin module)
provider "snowflake" {
  alias                      = "accountadmin"
  role                       = "ACCOUNTADMIN"
  organization_name          = var.snowflake_organization_name
  account_name               = var.snowflake_account_name
  user                       = "TERRAFORM_BLOG_USER"
  authenticator              = "WORKLOAD_IDENTITY"
  workload_identity_provider = "AWS"
  warehouse                  = "TERRAFORM_BLOG_WH"
  preview_features_enabled = [
    "snowflake_storage_integration_resource"
  ]
}

provider "aws" {
  region = "ap-northeast-1"
}

In Snowflake Provider 2.11.0, when you set authenticator = "WORKLOAD_IDENTITY", it automatically detects and uses the AWS temporary credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) set in environment variables.

environments/dev/variable.tf
variable "snowflake_organization_name" {
  type        = string
  description = "Snowflake organization name"
}

variable "snowflake_account_name" {
  type        = string
  description = "Snowflake account name"
}

Delete the snowflake_private_key variable that was required for JWT authentication.
It's now simply organization name and account name only.

.github/workflows/dev-snowflake-terraform-cicd.yml
name: "Snowflake Terraform CI/CD - DEV"

on:
  push:
    branches:
      - develop
    paths:
      - "environments/dev/**"
      - "modules/**"
      - ".github/workflows/dev-snowflake-terraform-cicd.yml"
  pull_request:
    branches:
      - develop
    paths:
      - "environments/dev/**"
      - "modules/**"
      - ".github/workflows/dev-snowflake-terraform-cicd.yml"

jobs:
  plan-dev:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment: dev

    env:
      # Terraform
      TF_VERSION: "1.11.0"
      # Snowflake - TF_VAR_ prefix maps to Terraform variables
      TF_VAR_snowflake_organization_name: ${{ secrets.SNOWFLAKE_ORG_NAME }}
      TF_VAR_snowflake_account_name: ${{ secrets.SNOWFLAKE_ACCOUNT_NAME }}

    permissions:
      id-token: write # Required when using OIDC
      contents: read # Required for actions/checkout
      pull-requests: write # For posting PR comments

    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

      - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Set up AWS credentials
        uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
          audience: sts.amazonaws.com

      - name: Terraform format
        run: terraform fmt -check -recursive
        working-directory: environments/dev

      - name: Terraform Init
        run: terraform init -upgrade -no-color
        working-directory: environments/dev

      - name: Terraform validate
        run: terraform validate -no-color
        working-directory: environments/dev

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@acd1575d3c037258ce5b2dd01379dc49ce24c6b7 # v6.2.0
        with:
          tflint_version: v0.58.0

      - name: Run TFLint
        run: tflint --init && tflint -f compact --minimum-failure-severity=error
        working-directory: environments/dev

      - name: Run Trivy Security Scan
        uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
        with:
          scan-type: "config"
          scan-ref: "environments/dev"
          format: "table"
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true

      - name: Terraform Plan
        id: plan
        if: github.event_name == 'pull_request'
        run: |
          terraform plan -no-color -out=tfplan.binary
          terraform show -no-color tfplan.binary > tfplan.txt
        working-directory: environments/dev

      - name: Post Plan to PR
        if: github.event_name == 'pull_request' && steps.plan.outcome == 'success'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('environments/dev/tfplan.txt', 'utf8');

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `### Terraform Plan - Dev

            <details>
            <summary>Show Plan</summary>

            \`\`\`terraform
            ${plan.substring(0, 65000)}
            \`\`\`

            </details>`
            });

      - name: Terraform Apply
        if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
        run: terraform apply -auto-approve
        working-directory: environments/dev

GitHub Actions uses AWS OIDC authentication to assume the IAM Role.
The aws-actions/configure-aws-credentials action sets AWS temporary credentials as environment variables, which the Terraform Snowflake Provider automatically detects and uses.

Important Note on Storage Integration Implementation

This is unrelated to WIF, but I encountered an error with storage integration after version upgrade, so I'm leaving this note. In Snowflake Provider 2.5.0 and later, the storage_aws_external_id attribute of the snowflake_storage_integration resource must be accessed via describe_output.

modules/data_engineer/main.tf
// Incorrect implementation
resource "aws_iam_role" "snowflake_access" {
  assume_role_policy = jsonencode({
    Statement = [{
      Principal = {
        AWS = snowflake_storage_integration.s3_integ.storage_aws_iam_user_arn
      }
      Condition = {
        StringEquals = {
          # Direct access (error in Provider 2.5.0+)
          "sts:ExternalId" = snowflake_storage_integration.s3_integ.storage_aws_external_id
        }
      }
    }]
  })
}
modules/data_engineer/main.tf
// Correct implementation
resource "aws_iam_role" "snowflake_access" {
  name = var.aws_iam_role_name
  path = "/"

  # storage_aws_iam_user_arn: can be accessed directly
  # storage_aws_external_id: must be accessed via describe_output (Provider 2.5.0+ specification)
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "TrustIamUserOfSnowflakePolicy"
        Effect = "Allow"
        Principal = {
          AWS = snowflake_storage_integration.s3_integ.storage_aws_iam_user_arn
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            # Access via describe_output
            "sts:ExternalId" = snowflake_storage_integration.s3_integ.describe_output[0].storage_aws_external_id[0].value
          }
        }
      }
    ]
  })

  tags = var.aws_tags
}

describe_output is a structured representation of the result of Snowflake's DESCRIBE INTEGRATION command. In Provider 2.5.0 and later, storage_aws_external_id must be accessed this way. The official documentation also has the following note:

Currently, describe_output field is not used in all the relevant fields (only storage_aws_external_id is supported). This will be addressed during the resource rework.

https://registry.terraform.io/providers/snowflakedb/snowflake/2.5.0/docs/resources/storage_integration

Deployment

After completing the implementation, deploy via GitHub Actions.

※ Although we won't try it this time, if you want to deploy using the terraform command locally, I think you can do so by setting AWS_PROFILE and configuring Terraform variables (TF_VAR_snowflake_organization_name, TF_VAR_snowflake_account_name) as environment variables.

Implement in a feature branch and push.

git add .
git commit -m "feat(dev): Add Workload Identity Federation authentication"
git push

Create a Pull Request from the feature branch to the develop branch on GitHub.
Screenshot 2025-12-10 at 20.56.44

When the Pull Request is created, GitHub Actions will run automatically, and the results of terraform plan will be commented on the PR.
Screenshot 2025-12-10 at 20.58.12

After approving and merging the PR, GitHub Actions will automatically run terraform apply and deploy the resources.
Screenshot 2025-12-10 at 21.01.32

From the Snowflake interface, we can confirm the RAW_DATA_DEV database that we deployed.
Screenshot 2025-12-10 at 21.02.22

Using the login_history table, we can verify that the Terraform user is actually logging in with WIF authentication.

USE ROLE ACCOUNTADMIN;

-- Check login history for WIF authentication
SELECT
  event_timestamp,
  user_name,
  first_authentication_factor,  -- Verify this is "WORKLOAD_IDENTITY"
  is_success
FROM snowflake.account_usage.login_history
WHERE user_name = 'TERRAFORM_BLOG_USER'
  AND event_timestamp > DATEADD(hour, -24, CURRENT_TIMESTAMP())
ORDER BY event_timestamp DESC
LIMIT 5;

Since first_authentication_factor is WORKLOAD_IDENTITY, we can confirm that authentication is working as expected.
Screenshot 2025-12-10 at 21.08.53

Using the query_history table, we can see the queries executed by Terraform Provider 2.11.0.

USE ROLE ACCOUNTADMIN;

-- Check Terraform execution history
SELECT
  start_time,
  user_name,
  role_name,
  query_text,
  execution_status
FROM snowflake.account_usage.query_history
WHERE user_name = 'TERRAFORM_BLOG_USER'
  AND start_time > DATEADD(hour, -24, CURRENT_TIMESTAMP())
ORDER BY start_time DESC
LIMIT 10;

We can see the DESCRIBE, CREATE, GRANT, and other queries executed by Terraform.
Screenshot 2025-12-10 at 21.13.36

Conclusion

I hope this serves as a reference for those who are building with Snowflake × Terraform or considering migrating from existing key pairs to WIF.

Share this article

FacebookHatena blogX

Related articles