IaC AWS

Terraform Module: AWS IAM User — Governed Programmatic Identities Without Long-Lived Console Sprawl

Quick take — A reusable Terraform module for hashicorp/aws ~> 5.0 that provisions an AWS IAM user with managed-policy attachments, an optional inline policy, and an access key — with PGP-encrypted secrets and guardrails. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "iam_user" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-user?ref=v1.0.0"

  name = "..."  # Name of the IAM user; unique within the account (1-64 c…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS IAM user is a long-lived identity inside your account that represents a single person or, more commonly in modern setups, a non-human consumer — a CI/CD runner that lives outside AWS, a third-party SaaS integration, or a legacy on-prem service that cannot assume a role via STS. Unlike an IAM role, a user carries durable credentials: a console password and/or one or two access keys. That durability is exactly why IAM users are the part of IAM most likely to rot — orphaned keys, no rotation, over-broad policies copy-pasted from a wiki.

Wrapping aws_iam_user in a module turns that risk into a contract. Every user this module creates gets a consistent ARN path, a permissions_boundary you can mandate org-wide, tags that satisfy your tagging policy, and a single, reviewable place where the attached policies live. Access keys are created only when you opt in, and their secret material is returned PGP-encrypted so the plaintext secret never lands in your state file or CI logs in the clear. The result: programmatic identities that are auditable, rotatable, and impossible to provision without a boundary.

When to use it

Do not reach for this module when a workload runs inside AWS (EC2, Lambda, ECS, EKS) — use an IAM role and an instance/task/IRSA profile instead. For human access at scale, prefer IAM Identity Center. IAM users are the deliberate exception, not the default.

Module structure

terraform-module-aws-iam-user/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf
resource "aws_iam_user" "this" {
  name                 = var.name
  path                 = var.path
  permissions_boundary = var.permissions_boundary
  force_destroy        = var.force_destroy

  tags = merge(
    var.tags,
    { "ManagedBy" = "terraform" },
  )
}

# Attach AWS-managed or customer-managed policies by ARN.
resource "aws_iam_user_policy_attachment" "managed" {
  for_each = toset(var.managed_policy_arns)

  user       = aws_iam_user.this.name
  policy_arn = each.value
}

# Optional single inline policy for tightly-scoped, user-specific permissions.
resource "aws_iam_user_policy" "inline" {
  count = var.inline_policy_json != null ? 1 : 0

  name   = "${var.name}-inline"
  user   = aws_iam_user.this.name
  policy = var.inline_policy_json
}

# Optional access key. Disabled by default; opt in only for true programmatic use.
resource "aws_iam_access_key" "this" {
  count = var.create_access_key ? 1 : 0

  user    = aws_iam_user.this.name
  status  = var.access_key_status
  pgp_key = var.pgp_key
}
# variables.tf
variable "name" {
  description = "Name of the IAM user. Must be unique within the AWS account."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9+=,.@_-]{1,64}$", var.name))
    error_message = "IAM user name must be 1-64 chars and contain only alphanumerics and + = , . @ _ - characters."
  }
}

variable "path" {
  description = "IAM path for the user, used to organize identities (e.g. /service-accounts/). Must start and end with a slash."
  type        = string
  default     = "/"

  validation {
    condition     = can(regex("^/.*/$", var.path)) || var.path == "/"
    error_message = "Path must start and end with a forward slash (e.g. /service-accounts/)."
  }
}

variable "permissions_boundary" {
  description = "ARN of the policy to set as the permissions boundary for this user. Strongly recommended to cap the maximum effective permissions."
  type        = string
  default     = null
}

variable "force_destroy" {
  description = "When true, destroys the user even if it has non-Terraform-managed access keys, signing certificates, or login profile. Use with care."
  type        = bool
  default     = false
}

variable "managed_policy_arns" {
  description = "List of IAM managed policy ARNs (AWS-managed or customer-managed) to attach to the user."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for arn in var.managed_policy_arns : can(regex("^arn:aws[a-z-]*:iam::(aws|[0-9]{12}):policy/", arn))])
    error_message = "Each managed_policy_arns entry must be a valid IAM policy ARN."
  }
}

variable "inline_policy_json" {
  description = "Optional inline IAM policy document (JSON) for permissions unique to this user. Set null to omit."
  type        = string
  default     = null
}

variable "create_access_key" {
  description = "Whether to create a long-lived access key for programmatic access. Keep false unless static credentials are unavoidable."
  type        = bool
  default     = false
}

variable "access_key_status" {
  description = "Status of the access key, either Active or Inactive."
  type        = string
  default     = "Active"

  validation {
    condition     = contains(["Active", "Inactive"], var.access_key_status)
    error_message = "access_key_status must be either 'Active' or 'Inactive'."
  }
}

variable "pgp_key" {
  description = "Base64-encoded PGP public key, or a keybase username in the form keybase:user. When set, the returned secret is encrypted. Required in practice to avoid plaintext secrets in state."
  type        = string
  default     = null
}

variable "tags" {
  description = "Map of tags to assign to the IAM user."
  type        = map(string)
  default     = {}
}
# outputs.tf
output "id" {
  description = "The unique ID (stable, GUID-like) assigned to the IAM user."
  value       = aws_iam_user.this.unique_id
}

output "name" {
  description = "The name of the IAM user."
  value       = aws_iam_user.this.name
}

output "arn" {
  description = "The ARN of the IAM user, for use in policy principals and trust statements."
  value       = aws_iam_user.this.arn
}

output "path" {
  description = "The IAM path of the user."
  value       = aws_iam_user.this.path
}

output "access_key_id" {
  description = "The access key ID, if an access key was created. Null otherwise."
  value       = var.create_access_key ? aws_iam_access_key.this[0].id : null
}

output "encrypted_secret_access_key" {
  description = "PGP-encrypted secret access key (base64). Decrypt with the matching private key. Null if no key or no pgp_key."
  value       = var.create_access_key ? aws_iam_access_key.this[0].encrypted_secret : null
}

output "secret_access_key" {
  description = "Plaintext secret access key — populated ONLY when create_access_key is true and no pgp_key is supplied. Marked sensitive."
  value       = var.create_access_key && var.pgp_key == null ? aws_iam_access_key.this[0].secret : null
  sensitive   = true
}

How to use it

module "iam_user" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-user?ref=v1.0.0"

  name = "svc-datadog-integration"
  path = "/service-accounts/"

  permissions_boundary = "arn:aws:iam::123456789012:policy/boundary-saas-readonly"

  managed_policy_arns = [
    "arn:aws:iam::aws:policy/SecurityAudit",
  ]

  inline_policy_json = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "AllowCloudWatchMetricsRead"
        Effect   = "Allow"
        Action   = ["cloudwatch:GetMetricData", "cloudwatch:ListMetrics", "tag:GetResources"]
        Resource = "*"
      },
    ]
  })

  create_access_key = true
  pgp_key           = "keybase:kloudvin_ops"

  tags = {
    Environment = "prod"
    Owner       = "platform-team"
    Integration = "datadog"
  }
}

# Downstream: grant this user's ARN read access to a specific S3 bucket
# by referencing the module's arn output in a bucket policy principal.
resource "aws_s3_bucket_policy" "audit_logs" {
  bucket = aws_s3_bucket.audit_logs.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowIntegrationUserRead"
        Effect    = "Allow"
        Principal = { AWS = module.iam_user.arn }
        Action    = ["s3:GetObject", "s3:ListBucket"]
        Resource = [
          aws_s3_bucket.audit_logs.arn,
          "${aws_s3_bucket.audit_logs.arn}/*",
        ]
      },
    ]
  })
}

After apply, retrieve and decrypt the secret locally — it is never stored in plaintext in state:

terraform output -raw encrypted_secret_access_key | base64 --decode | keybase pgp decrypt

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/iam_user/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-user?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/iam_user && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Name of the IAM user; unique within the account (1-64 chars).
path string “/” No IAM path used to organize identities (e.g. /service-accounts/).
permissions_boundary string null No ARN of the policy used as the user’s permissions boundary.
force_destroy bool false No Destroy the user even if it has non-Terraform-managed keys/login profile.
managed_policy_arns list(string) [] No Managed policy ARNs to attach to the user.
inline_policy_json string null No Optional inline IAM policy document (JSON) for user-specific permissions.
create_access_key bool false No Whether to create a long-lived access key for programmatic access.
access_key_status string “Active” No Access key status: Active or Inactive.
pgp_key string null No Base64 PGP public key or keybase:user to encrypt the returned secret.
tags map(string) {} No Tags to assign to the IAM user.

Outputs

Name Description
id The unique, stable ID assigned to the IAM user.
name The name of the IAM user.
arn The ARN of the IAM user, for policy principals and trust statements.
path The IAM path of the user.
access_key_id The access key ID, if an access key was created (null otherwise).
encrypted_secret_access_key PGP-encrypted secret access key (base64); null if no key/pgp_key.
secret_access_key Plaintext secret (sensitive); only when create_access_key is true and pgp_key is null.

Enterprise scenario

A retail company onboards a third-party fraud-detection SaaS that ingests CloudWatch metrics and S3 access logs but only supports static AWS access keys — no cross-account role assumption. The platform team stamps out svc-fraud-detect from this module in the security-tooling account with path = "/vendors/", the org-mandated boundary-saas-readonly permissions boundary, a least-privilege inline policy, and an access key encrypted under the SecOps team’s Keybase PGP key. The encrypted secret is handed to SecOps for one-time delivery to the vendor, never touching the CI logs, and AWS Config flags the user for the quarterly key-rotation playbook because of its Integration tag.

Best practices

TerraformAWSIAM UserModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading