This document explains why cross-account roles and temporary credentials are significantly more secure than traditional access keys.
| Security Aspect | Cross-Account Roles | Access Keys |
|---|---|---|
| Credential Lifetime | β Temporary (1 hour default) | β Permanent (until manually rotated) |
| Permission Scope | β Least privilege, resource-specific | β Often over-privileged |
| Secret Storage | β No long-lived secrets | β Permanent secrets in databases |
| Revocation Speed | β Instant (delete CloudFormation stack) | β Manual, often forgotten |
| Audit Trail | β Complete CloudTrail logging | β Limited visibility |
| Leak Impact | β Minimal (expires quickly) | β Full account compromise |
| Rotation Required | β Automatic | β Manual process, rarely done |
# Access keys never expire unless manually rotated
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE # Valid forever
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # Valid foreverProblems:
- Keys remain valid indefinitely
- Often forgotten in configuration files
- Difficult to track usage and rotate
- Create permanent attack surface
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "*", # β Often AdminAccess because IAM is complex
"Resource": "*" # β Full access to everything
}]
}Problems:
- Users often grant admin access "to be safe"
- Precise permissions are hard to configure
- Blast radius of compromise is entire AWS account
- No resource-level restrictions
# These secrets end up everywhere:
- Application configuration files
- Environment variables
- Container images
- Git repositories (accidentally)
- Log files
- Backup systems
- Developer machinesProblems:
- Secrets stored in multiple locations
- Hard to track where credentials are used
- Accidental exposure in logs/repos
- Difficult to rotate without breaking things
# Manual rotation process (rarely done):
1. Generate new access keys
2. Update all applications using old keys
3. Test that everything still works
4. Delete old keys
5. Hope you didn't miss any usageProblems:
- Manual process that's rarely executed
- Risk of breaking applications
- No automated rotation mechanism
- Keys often remain active long past needed
// Credentials automatically expire
temporaryCreds, err := client.AssumeRole(ctx, customerID)
// These expire in 1 hour by default
// No permanent secrets anywhereBenefits:
- All credentials have expiration times
- Automatic refresh mechanism
- Limited blast radius if compromised
- No permanent secrets to manage
# Precise, minimal permissions
OngoingPermissions:
- Sid: "S3DataAccess"
Effect: "Allow"
Actions:
- "s3:GetObject"
- "s3:PutObject"
Resources:
- "arn:aws:s3:::customer-data-bucket/*" # Only specific resourcesBenefits:
- Permissions scoped to specific resources
- Separate setup vs ongoing permissions
- Easy to audit and understand
- Follows principle of least privilege
// No secrets stored in your application
type CustomerCredentials struct {
RoleARN string // Not a secret - can be logged
ExternalID string // Not reusable without your AWS account
// No AccessKey or SecretKey stored anywhere!
}Benefits:
- No long-lived secrets in databases
- External IDs are not reusable
- Role ARNs are not sensitive information
- Nothing to leak or compromise
# Customer can revoke access instantly
aws cloudformation delete-stack --stack-name MyService-Integration
# Done! All access immediately revokedBenefits:
- One-command revocation by customer
- No coordination required with service provider
- Immediate effect (no propagation delay)
- Customer maintains full control
1. Developer commits .env file with access keys to GitHub
2. Keys are discovered by automated scanners
3. Attacker uses keys to access AWS account
4. Keys have AdminAccess (common over-permissioning)
5. Entire AWS account is compromised
6. Keys remain valid until manually rotated (often never)
7. Damage: Complete account takeover
1. Temporary credentials are somehow leaked
2. Attacker tries to use credentials
3. Credentials have limited scope (only S3 bucket access)
4. Credentials expire in 1 hour
5. No permanent access gained
6. Customer can revoke access by deleting CloudFormation stack
7. Damage: Limited to specific resources for short time
{
"eventTime": "2025-01-15T10:30:00Z",
"eventName": "AssumeRole",
"userIdentity": {
"type": "AssumedRole",
"principalId": "AIDACKCEVSQ6C2EXAMPLE:MyService-acme-corp-1673771400",
"arn": "arn:aws:sts::123456789012:assumed-role/MyService-CrossAccount/MyService-acme-corp-1673771400"
},
"sourceIPAddress": "203.0.113.12",
"resources": [{
"ARN": "arn:aws:iam::123456789012:role/MyService-CrossAccount",
"accountId": "123456789012"
}]
}Benefits:
- Every role assumption logged
- Clear attribution to specific service/customer
- Detailed resource access logging
- Integration with compliance tools
# Built-in permission boundaries
MaxSessionDuration: 3600 # 1 hour maximum
Condition:
StringEquals:
"sts:ExternalId": "unique-customer-id" # Only your service can assume
IpAddress:
"aws:SourceIp": "203.0.113.0/24" # Optional IP restrictionsBenefits:
- Session duration limits
- IP address restrictions possible
- MFA requirements supported
- Integration with AWS Organizations SCPs
Major SaaS providers using cross-account roles:
- Datadog: Monitoring and observability
- Coiled: Data science platform
- Snowflake: Data warehouse
- Databricks: Analytics platform
- New Relic: Application monitoring
- Sumo Logic: Log analytics
Why they switched:
- Better security posture
- Easier customer onboarding
- Compliance requirements
- Reduced support burden
- Customer demand for better security
// Use cryptographically secure random generation
func generateExternalID(customerID string) string {
randomBytes := make([]byte, 16)
if _, err := rand.Read(randomBytes); err != nil {
// Never use predictable fallbacks in production
panic("Failed to generate secure external ID")
}
return fmt.Sprintf("%s-%s-%s", serviceName, customerID, hex.EncodeToString(randomBytes))
}// Always test role assumptions during setup
func (c *Client) CompleteSetup(ctx context.Context, req *SetupCompleteRequest) error {
// Test that we can actually assume the role
if err := c.validateRoleAccess(ctx, req.RoleARN, req.ExternalID); err != nil {
return fmt.Errorf("role validation failed: %w", err)
}
// ... store only after validation
}// Cache credentials but respect expiration
func (c *Client) AssumeRole(ctx context.Context, customerID string) (aws.Config, error) {
if cached := c.cache.Get(customerID); cached != nil {
if time.Now().Before(cached.ExpiresAt.Add(-5*time.Minute)) { // 5min buffer
return cached.Config, nil
}
}
// ... assume role and cache result
}-
Phase 1: Implement cross-account support
// Add cross-account client alongside existing access key support crossAccountClient, _ := crossaccount.New(config)
-
Phase 2: Customer migration
// Support both methods during transition if customer.HasCrossAccountRole { return crossAccountClient.AssumeRole(ctx, customerID) } else { return legacyAccessKeyAuth(customer.AccessKey, customer.SecretKey) }
-
Phase 3: Deprecate access keys
// Gradually migrate customers and sunset access key support if customer.UsingAccessKeys && time.Since(customer.CreatedAt) > 90*24*time.Hour { log.Warn("Customer still using deprecated access keys") // Send migration reminder }
- No more "my access keys stopped working" tickets
- No manual rotation coordination with customers
- Automatic credential refresh eliminates expiration issues
- Customers maintain control over access
- Transparent permission model
- Follows AWS security best practices
- Easy to audit and demonstrate compliance
- Modern, enterprise-grade security model
- Easier procurement process for enterprise customers
- Differentiator against competitors using access keys
Bottom Line: Cross-account roles provide significantly better security, user experience, and operational benefits compared to traditional access keys. The initial implementation effort pays dividends in reduced security risk, better customer satisfaction, and lower operational overhead.