September 7, 2026
Stop Handing Out Permanent AWS Access: Build a Safer Alternative
By Raunak Balchandani
7 min read
Permanent production access is convenient; right up to the moment an account is compromised, a command targets the wrong environment, or an old permission is quietly forgotten.
The safer model is just-in-time access:
- No standing production privilege
- Access granted for a specific reason
- A different person approves it
- The permission is narrowly scoped
- The assignment disappears automatically
- Every step leaves an audit trail
In this implementation, we will build that workflow using AWS IAM Identity Center, API Gateway, Lambda, Step Functions, DynamoDB, SNS, and CloudTrail.
The objective is deliberately small. We are not building an enterprise privileged-access-management product. We are building a working foundation that demonstrates the control loop correctly. But if implemented correctly, this can be definetely scaled at an enterprise level.
JIT access is not the same as break-glass access
These terms are frequently mixed together:
- Just-in-time access follows the normal control path: request, approval, temporary access, expiration.
- Break-glass access is an emergency path used when the normal identity or approval system is unavailable or too slow. A real production design should maintain a separate, heavily monitored break-glass mechanism.
What we are building
The workflow looks like this:
- An engineer requests a predefined permission set for a permitted AWS account.
- The request service validates the engineer's eligibility and records the request.
- Step Functions pauses and waits for an independent approver.
- An approval creates a temporary IAM Identity Center account assignment.
- The engineer opens a short-lived AWS role session through the access portal or CLI.
- At the end of the approved window, the workflow deletes the assignment.
- CloudTrail preserves the AWS activity performed during the session.
Prerequisites
You need:
- An AWS Organization
- An organization instance of IAM Identity Center with multi-account permissions enabled
- One sandbox AWS account to represent production
- One requester and one separate approver identity
- Terraform 1.6 or later
- AWS CLI v2 and Python 3.12
Step 1: Create a task-specific permission set
Avoid making AdministratorAccess the tutorial's default. A JIT workflow does not make an oversized role safe.
This code creates a production diagnostics permission set. It permits common read-only incident-investigation actions but cannot modify workloads.
resource "aws_ssoadmin_permission_set" "production_diagnostics" {
instance_arn = var.sso_instance_arn
name = "ProductionDiagnostics"
description = "Temporary access for production incident diagnosis"
session_duration = "PT1H"
}
resource "aws_ssoadmin_permission_set_inline_policy" "production_diagnostics" {
instance_arn = var.sso_instance_arn
permission_set_arn = aws_ssoadmin_permission_set.production_diagnostics.arn
inline_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "InspectCompute"
Effect = "Allow"
Action = [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus"
]
Resource = "*"
},
{
Sid = "InspectTelemetry"
Effect = "Allow"
Action = [
"cloudwatch:DescribeAlarms",
"cloudwatch:GetMetricData",
"logs:DescribeLogGroups",
"logs:StartQuery",
"logs:GetQueryResults"
]
Resource = "*"
}
]
})
}
resource "aws_ssoadmin_permission_set" "production_diagnostics" {
instance_arn = var.sso_instance_arn
name = "ProductionDiagnostics"
description = "Temporary access for production incident diagnosis"
session_duration = "PT1H"
}
resource "aws_ssoadmin_permission_set_inline_policy" "production_diagnostics" {
instance_arn = var.sso_instance_arn
permission_set_arn = aws_ssoadmin_permission_set.production_diagnostics.arn
inline_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "InspectCompute"
Effect = "Allow"
Action = [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus"
]
Resource = "*"
},
{
Sid = "InspectTelemetry"
Effect = "Allow"
Action = [
"cloudwatch:DescribeAlarms",
"cloudwatch:GetMetricData",
"logs:DescribeLogGroups",
"logs:StartQuery",
"logs:GetQueryResults"
]
Resource = "*"
}
]
})
}
These resources are supported by the current HashiCorp AWS provider; its documentation also recommends jsonencode() for inline permission-set policies. [Terraform provider documentation]
IAM Identity Center permits permission-set sessions from one to twelve hours; one hour is the minimum. Keep it at one hour unless the task genuinely requires more. [AWS session-duration documentation]
The permission set defines what a session can do The JIT workflow controls whether the engineer can start that session.
Step 2: Store the request and its security context
The request record needs more than a status field. It should capture enough context to reconstruct the decision later:
{
"request_id": "01JIT8S2RX7K31V7",
"requester_principal_id": "9067d821-...",
"requester_arn": "arn:aws:sts::111122223333:assumed-role/Engineer/alex",
"target_account_id": "444455556666",
"permission_set_arn": "arn:aws:sso:::permissionSet/ssoins-.../ps-...",
"reason": "Investigate INC-2841 latency regression",
"requested_at": "2026-09-02T14:00:00Z",
"assignment_expires_at": "2026-09-02T14:30:00Z",
"status": "PENDING"
}{
"request_id": "01JIT8S2RX7K31V7",
"requester_principal_id": "9067d821-...",
"requester_arn": "arn:aws:sts::111122223333:assumed-role/Engineer/alex",
"target_account_id": "444455556666",
"permission_set_arn": "arn:aws:sso:::permissionSet/ssoins-.../ps-...",
"reason": "Investigate INC-2841 latency regression",
"requested_at": "2026-09-02T14:00:00Z",
"assignment_expires_at": "2026-09-02T14:30:00Z",
"status": "PENDING"
}A DynamoDB table provides conditional writes, simple lookups, and TTL cleanup:
resource "aws_dynamodb_table" "requests" {
name = "jit-access-requests"
billing_mode = "PAY_PER_REQUEST"
hash_key = "request_id"
attribute {
name = "request_id"
type = "S"
}
ttl {
attribute_name = "delete_after"
enabled = true
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
}resource "aws_dynamodb_table" "requests" {
name = "jit-access-requests"
billing_mode = "PAY_PER_REQUEST"
hash_key = "request_id"
attribute {
name = "request_id"
type = "S"
}
ttl {
attribute_name = "delete_after"
enabled = true
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
}Do not trust principal_id, target_account_id, or permission_set_arn merely because they appeared in the request body. The request Lambda should derive the caller from API Gateway's IAM authorization context, then resolve the permitted principal and scope from an administrator-controlled eligibility mapping.
That prevents a requester from simply typing another user's ID or asking for a more powerful permission set.
Step 3: Pause for human approval
Step Functions supports callback tasks: the workflow pauses until another trusted process returns a task token with SendTaskSuccess or SendTaskFailure. That makes it a natural fit for human approval. [AWS callback documentation]
The important states are:
{
"StartAt": "WaitForIndependentApproval",
"TimeoutSeconds": 7200,
"States": {
"WaitForIndependentApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "${notify_approver_lambda_arn}",
"Payload": {
"task_token.$": "$$.Task.Token",
"request.$": "$"
}
},
"ResultPath": "$.approval",
"TimeoutSeconds": 1800,
"Next": "GrantAccess",
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "RequestClosed"
}]
},
"GrantAccess": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${access_manager_lambda_arn}",
"Payload": {
"action": "grant",
"request.$": "$"
}
},
"ResultPath": "$.grant_result",
"Next": "WaitForAssignmentExpiry"
},
"WaitForAssignmentExpiry": {
"Type": "Wait",
"TimestampPath": "$.approval.expires_at",
"Next": "RevokeAccess"
},
"RevokeAccess": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${access_manager_lambda_arn}",
"Payload": {
"action": "revoke",
"request.$": "$"
}
},
"Next": "RequestClosed"
},
"RequestClosed": {
"Type": "Succeed"
}
}
}
{
"StartAt": "WaitForIndependentApproval",
"TimeoutSeconds": 7200,
"States": {
"WaitForIndependentApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "${notify_approver_lambda_arn}",
"Payload": {
"task_token.$": "$$.Task.Token",
"request.$": "$"
}
},
"ResultPath": "$.approval",
"TimeoutSeconds": 1800,
"Next": "GrantAccess",
"Catch": [{
"ErrorEquals": ["States.ALL"],
"Next": "RequestClosed"
}]
},
"GrantAccess": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${access_manager_lambda_arn}",
"Payload": {
"action": "grant",
"request.$": "$"
}
},
"ResultPath": "$.grant_result",
"Next": "WaitForAssignmentExpiry"
},
"WaitForAssignmentExpiry": {
"Type": "Wait",
"TimestampPath": "$.approval.expires_at",
"Next": "RevokeAccess"
},
"RevokeAccess": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${access_manager_lambda_arn}",
"Payload": {
"action": "revoke",
"request.$": "$"
}
},
"Next": "RequestClosed"
},
"RequestClosed": {
"Type": "Succeed"
}
}
}
Use a Standard Workflow, not an Express Workflow. Standard workflows support long-running, auditable executions, and a Wait state can wait until an absolute timestamp. [AWS Wait-state documentation]
The notification Lambda stores the task token in the encrypted request table and emails only a request ID, not the token itself:
import os
import boto3
dynamodb = boto3.resource("dynamodb")
sns = boto3.client("sns")
table = dynamodb.Table(os.environ["REQUEST_TABLE"])
def handler(event, _context):
request = event["request"]
table.update_item(
Key={"request_id": request["request_id"]},
UpdateExpression="SET task_token = :token, #s = :status",
ExpressionAttributeNames={"#s": "status"},
ConditionExpression="#s = :expected",
ExpressionAttributeValues={
":token": event["task_token"],
":status": "PENDING_APPROVAL",
":expected": "PENDING",
},
)
sns.publish(
TopicArn=os.environ["APPROVAL_TOPIC_ARN"],
Subject=f"JIT access request {request['request_id']}",
Message=(
f"Requester: {request['requester_arn']}\n"
f"Account: {request['target_account_id']}\n"
f"Reason: {request['reason']}\n"
f"Approve or reject through the authenticated approval endpoint."
),
)
return {"notification": "sent"}import os
import boto3
dynamodb = boto3.resource("dynamodb")
sns = boto3.client("sns")
table = dynamodb.Table(os.environ["REQUEST_TABLE"])
def handler(event, _context):
request = event["request"]
table.update_item(
Key={"request_id": request["request_id"]},
UpdateExpression="SET task_token = :token, #s = :status",
ExpressionAttributeNames={"#s": "status"},
ConditionExpression="#s = :expected",
ExpressionAttributeValues={
":token": event["task_token"],
":status": "PENDING_APPROVAL",
":expected": "PENDING",
},
)
sns.publish(
TopicArn=os.environ["APPROVAL_TOPIC_ARN"],
Subject=f"JIT access request {request['request_id']}",
Message=(
f"Requester: {request['requester_arn']}\n"
f"Account: {request['target_account_id']}\n"
f"Reason: {request['reason']}\n"
f"Approve or reject through the authenticated approval endpoint."
),
)
return {"notification": "sent"}Notice the DynamoDB condition. Duplicate messages and retries should not silently overwrite a request that has already moved forward.
Step 4: Create and remove the account assignment
IAM Identity Center account-assignment operations are asynchronous. Calling create_account_assignment successfully means the request was accepted; not necessarily that access is ready.
The access manager must poll the operation until it succeeds or fails:
import os
import time
import boto3
sso = boto3.client("sso-admin")
INSTANCE_ARN = os.environ["SSO_INSTANCE_ARN"]
def wait_for(operation, request_id, timeout_seconds=120):
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if operation == "grant":
response = sso.describe_account_assignment_creation_status(
InstanceArn=INSTANCE_ARN,
AccountAssignmentCreationRequestId=request_id,
)
result = response["AccountAssignmentCreationStatus"]
else:
response = sso.describe_account_assignment_deletion_status(
InstanceArn=INSTANCE_ARN,
AccountAssignmentDeletionRequestId=request_id,
)
result = response["AccountAssignmentDeletionStatus"]
if result["Status"] == "SUCCEEDED":
return
if result["Status"] == "FAILED":
raise RuntimeError(result.get("FailureReason", "assignment failed"))
time.sleep(3)
raise TimeoutError("IAM Identity Center operation timed out")
def handler(event, _context):
action = event["action"]
request = event["request"]
parameters = {
"InstanceArn": INSTANCE_ARN,
"TargetId": request["target_account_id"],
"TargetType": "AWS_ACCOUNT",
"PermissionSetArn": request["permission_set_arn"],
"PrincipalType": "USER",
"PrincipalId": request["requester_principal_id"],
}
if action == "grant":
response = sso.create_account_assignment(**parameters)
operation = response["AccountAssignmentCreationStatus"]
elif action == "revoke":
response = sso.delete_account_assignment(**parameters)
operation = response["AccountAssignmentDeletionStatus"]
else:
raise ValueError("action must be grant or revoke")
wait_for(action, operation["RequestId"])
return {"action": action, "status": "SUCCEEDED"}import os
import time
import boto3
sso = boto3.client("sso-admin")
INSTANCE_ARN = os.environ["SSO_INSTANCE_ARN"]
def wait_for(operation, request_id, timeout_seconds=120):
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if operation == "grant":
response = sso.describe_account_assignment_creation_status(
InstanceArn=INSTANCE_ARN,
AccountAssignmentCreationRequestId=request_id,
)
result = response["AccountAssignmentCreationStatus"]
else:
response = sso.describe_account_assignment_deletion_status(
InstanceArn=INSTANCE_ARN,
AccountAssignmentDeletionRequestId=request_id,
)
result = response["AccountAssignmentDeletionStatus"]
if result["Status"] == "SUCCEEDED":
return
if result["Status"] == "FAILED":
raise RuntimeError(result.get("FailureReason", "assignment failed"))
time.sleep(3)
raise TimeoutError("IAM Identity Center operation timed out")
def handler(event, _context):
action = event["action"]
request = event["request"]
parameters = {
"InstanceArn": INSTANCE_ARN,
"TargetId": request["target_account_id"],
"TargetType": "AWS_ACCOUNT",
"PermissionSetArn": request["permission_set_arn"],
"PrincipalType": "USER",
"PrincipalId": request["requester_principal_id"],
}
if action == "grant":
response = sso.create_account_assignment(**parameters)
operation = response["AccountAssignmentCreationStatus"]
elif action == "revoke":
response = sso.delete_account_assignment(**parameters)
operation = response["AccountAssignmentDeletionStatus"]
else:
raise ValueError("action must be grant or revoke")
wait_for(action, operation["RequestId"])
return {"action": action, "status": "SUCCEEDED"}AWS exposes separate status APIs for assignment creation and assignment deletion. Ignoring this detail creates race conditions that are especially dangerous in an access-control workflow.
For a larger deployment, replace the Lambda polling loop with Step Functions AWS SDK integrations and explicit wait states. That avoids holding a Lambda invocation while IAM Identity Center finishes provisioning.
Step 5: Secure the approval path
The approval endpoint should use IAM authorization and permit invocation only from an approver role. The Lambda then performs four checks:
- The request is still pending.
- The caller is in the approved approver mapping.
- The approver is not the requester.
- The requested duration and scope have not changed.
The final callback is small:
stepfunctions.send_task_success(
taskToken=request["task_token"],
output=json.dumps({
"approved": True,
"approved_by": approver_arn,
"approved_at": now,
"expires_at": assignment_expiry,
}),
)stepfunctions.send_task_success(
taskToken=request["task_token"],
output=json.dumps({
"approved": True,
"approved_by": approver_arn,
"approved_at": now,
"expires_at": assignment_expiry,
}),
)Do not put the Step Functions task token directly into an email approval link. Anyone possessing that token can complete the callback. Email the request ID and require the approver to authenticate to the approval endpoint.
The requester must never be able to approve their own request, even when requester and approver roles belong to the same team.
Step 6: Prove that the workflow works
A security control is incomplete until you test both its allowed and denied paths.
Before approval:
Using the engineer's normal profile:
aws ec2 describe-instance-status \
- profile engineer-baseline \
- region us-east-1aws ec2 describe-instance-status \
- profile engineer-baseline \
- region us-east-1
Expected result: An error occurred (UnauthorizedOperation): You are not authorized…
Submit the request
{
"target_account_id": "444455556666",
"permission_set": "ProductionDiagnostics",
"reason": "Investigate INC-2841 latency regression",
"assignment_window_minutes": 30
}{
"target_account_id": "444455556666",
"permission_set": "ProductionDiagnostics",
"reason": "Investigate INC-2841 latency regression",
"assignment_window_minutes": 30
}After the independent approval, IAM Identity Center displays ProductionDiagnostics for the target account. The engineer signs in and opens a role session:
aws sso login --profile production-diagnostics
aws ec2 describe-instance-status \
--profile production-diagnostics \
--region us-east-1aws sso login --profile production-diagnostics
aws ec2 describe-instance-status \
--profile production-diagnostics \
--region us-east-1The diagnostic call now succeeds. A write operation should still fail because the permission set is read-only.
After assignment expiration
When the workflow reaches RevokeAccess, it deletes the account assignment. The access portal should no longer offer that permission set, and the engineer cannot create another session from it.
The session-expiration trap
This is the most important detail in the entire design:
Removing an IAM Identity Center account assignment prevents new sessions. It does not instantly kill AWS role credentials that have already been issued.
Existing role sessions continue until the permission set's session duration expires. AWS documents this behavior explicitly in its authentication-session guidance
That means a 30-minute assignment window is not the same thing as 30-minute credentials. With a one-hour permission-set duration, an engineer who starts a session just before assignment removal could retain that session for nearly another hour.
Design both clocks intentionally:
- Assignment window: how long the engineer may start a new session.
- Role session duration: how long an issued session can continue.
For higher-risk permissions, use the one-hour minimum, monitor the session, and maintain a separate containment procedure for suspected compromise.
What production-ready looks like
The tutorial demonstrates the mechanism. A serious deployment should add:
Policy controls
- Permit only predefined account and permission-set combinations.
- Set maximum assignment windows by risk level.
- Require a ticket or incident identifier.
- Use two-person approval for highly privileged roles.
- Apply service control policies as a boundary above temporary roles.
Workflow controls
- Reject self-approval.
- Expire unanswered requests.
- Make grant and revoke operations idempotent.
- Retry throttled IAM Identity Center operations safely.
- Alert immediately when assignment removal fails.
- Provide an operator-controlled emergency revocation path.
Security controls
- Protect the broker with its own narrowly scoped execution roles.
- Encrypt the request table with a customer-managed KMS key.
- Never expose callback tokens in email or logs.
- Send CloudTrail to a separate security account.
- Alert on assignments created outside the broker.
- Regularly test the independent break-glass path.
Operational controls
- Track request count, approval latency, grant failures, and revocation failures.
- Attach request IDs to logs and audit records.
- Build a reconciliation job that finds expired but still-assigned access.
- Test the workflow when IAM Identity Center or the notification channel is degraded.
That reconciliation job matters. Workflows fail. The desired state should be independently verifiable: if an assignment exists without a live approved request, the system should flag or remove it.
Final thoughts
The goal of JIT access is not to make privileged access painless. It is to make privileged access intentional, temporary, attributable, and reversible.
An engineer should not carry production permissions every day because they might need them someday. They should receive the smallest useful capability when a real task requires it, and the system — not somebody's memory; should remove it afterward.
Start with one diagnostic permission set, one sandbox account, and one approval path. Test every failure mode. Then expand only after the control loop is dependable. Production access should be borrowed, not owned.