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
- A SaaS vendor (e.g. a monitoring or backup product) requires an access key/secret key pair and cannot use cross-account role assumption.
- An external CI/CD system (GitHub Actions without OIDC, an on-prem Jenkins, a partner pipeline) needs static credentials to call AWS APIs.
- A break-glass or legacy on-prem application authenticates with long-lived keys because STS/
AssumeRoleis not an option. - You need a human IAM user for the AWS console in an account that is not yet wired into IAM Identity Center (SSO).
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 config — live/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 config — live/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
- Default to roles; use users as the exception. Reserve this module for identities that genuinely cannot use STS/
AssumeRole(external CI, SaaS, on-prem). Anything running inside AWS should use an IAM role instead. - Always set a
permissions_boundary. A boundary caps the maximum effective permissions regardless of attached policies, so a future over-broad attachment cannot silently escalate. Mandate it via SCP/policy in regulated accounts. - Never leave secrets in plaintext. Always supply
pgp_keywhencreate_access_key = trueso the secret is encrypted; otherwise the secret lands in state. Treat thesecret_access_keyoutput as a last resort and keep state encrypted and access-controlled regardless. - Rotate and retire keys on a schedule. Limit each user to one active key, rotate at least every 90 days, and flip
access_key_statustoInactiveduring cutover to verify nothing breaks before deletion. Audit withaws iam get-credential-report. - Name and path for auditability. Use a stable convention like
svc-<system>-<purpose>and group withpath(/service-accounts/,/vendors/) so IAM Access Analyzer, Config rules, and humans can reason about every identity at a glance. - Tag for ownership and lifecycle. Require
Owner,Environment, and anIntegration/Justificationtag so orphaned or stale users are easy to attribute and decommission.