AWS para Engenheiros de Dados (Parte 4): AWS STS + OIDC - Autenticação Federada e CI/CD
Como eliminar chaves AWS hardcoded no CI/CD usando STS e OIDC. GitHub Actions, Terraform e boto3 para pipelines de dados seguros na AWS.
AWS para Engenheiros de Dados (Parte 4): AWS STS + OIDC - Autenticação Federada e CI/CD sem Segredos

Bem-vindo(a) à série AWS para Engenheiros de Dados (Parte 4). Esta série nasceu das minhas anotações, estudos e experiências práticas explorando o ecossistema AWS sob a perspectiva da engenharia de dados. Mais do que aprender serviços específicos, a proposta é entender os fundamentos que sustentam arquiteturas seguras, escaláveis e preparadas para produção.
Ao longo dos próximos capítulos, vamos explorar os principais serviços da AWS conectando teoria e prática, desde os conceitos essenciais até cenários reais de arquitetura, operação, certificações e ambientes produtivos. A ideia é construir uma visão sólida da plataforma, entendendo não apenas como usar cada serviço, mas principalmente por que ele existe e quais problemas ele resolve.
No episódio de hoje, vamos desmistificar o AWS STS e o OIDC (OpenID Connect). Vamos entender como eliminar de vez a vulnerabilidade número um em pipelines de dados: chaves fixas (hardcoded) em servidores de CI/CD. Mais do que teoria, vamos construir uma arquitetura real de federação de identidades, mostrando como orquestrar deploys automáticos no GitHub Actions com credenciais dinâmicas, zero senhas estáticas e governança de nível enterprise.
“O pior segredo é aquele que você nem sabe que está vazando. Chaves AWS hardcoded em CI/CD são a vulnerabilidade número 1 em pipelines de dados. STS + OIDC resolve isso de forma elegante - sem nenhuma senha estática.”

1. O QUE É STS?
STS = Security Token Service
É o serviço da AWS que emite credenciais temporárias. Em vez de uma chave que nunca expira (AWS_ACCESS_KEY_ID), o STS gera um trio de credenciais que vivem por minutos ou horas:
Access Key ID → começa com "ASIA..." (temporária)
Secret Access Key → gerada dinamicamente
Session Token → prova que é temporária, obrigatório junto
Expiration → timestamp de quando tudo vira lixo

2. O QUE É OIDC?
OIDC = OpenID Connect
É um protocolo de identidade baseado em OAuth 2.0. Permite que sistemas externos (GitHub Actions, GitLab CI, CircleCI, etc.) provem sua identidade para a AWS sem nenhuma chave estática.
Funciona assim: o GitHub sabe quem você é (repositório, branch, organização). Ele emite um JWT token assinado provando essa identidade. A AWS confia no GitHub como provedor de identidade e troca esse JWT por credenciais temporárias via STS.

3. O FLUXO COMPLETO - COMO CI/CD ACESSA AWS
Agora veja o fluxo completo com todos os participantes:

4. ARQUITETURA REAL - STS EM ENGENHARIA DE DADOS
Por que separar roles por contexto? Um PR aberto por qualquer contribuidor não deve ter poder de deploy. Você condiciona a role pelo branch.

5. EXEMPLOS PRÁTICOS
## Terraform - Configurando OIDC Provider + Roles
# ─── OIDC Provider (feito uma vez por conta AWS) ───────────────────
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
# GitHub emite tokens com este audience
client_id_list = ["sts.amazonaws.com"]
# Fingerprint do certificado TLS do GitHub
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}
# ─── Role para deploy de pipeline de dados ─────────────────────────
resource "aws_iam_role" "github_deploy" {
name = "github-oidc-data-deploy"
# Trust Policy: quem pode assumir essa role
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
# Audience obrigatório
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com",
# Usamos StringEquals aqui porque o deploy só ocorre na main exata
"token.actions.githubusercontent.com:sub" = "repo:empresa/data-platform:ref:refs/heads/main"
}
}
}
]
})
}
# Permissões da Role de deploy
resource "aws_iam_role_policy" "github_deploy_policy" {
name = "data-pipeline-deploy"
role = aws_iam_role.github_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
# ECR: push de imagens Docker do pipeline
{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "*"
},
# S3: sync de artefatos e dados processados
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:GetObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::empresa-artifacts/*",
"arn:aws:s3:::empresa-artifacts"
]
},
# Glue: disparar jobs de ETL
{
Effect = "Allow"
Action = [
"glue:StartJobRun",
"glue:GetJobRun",
"glue:GetJob"
]
Resource = "arn:aws:glue:sa-east-1:*:job/etl-*"
}
]
})
}
# ─── Role separada para PRs (somente leitura) ──────────────────────
resource "aws_iam_role" "github_readonly" {
name = "github-oidc-data-readonly"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Mantemos StringLike aqui com o curinga (*) para validar PRs de qualquer branch
"token.actions.githubusercontent.com:sub" = "repo:empresa/data-platform:*"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "github_readonly" {
role = aws_iam_role.github_readonly.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
## GitHub Actions - Workflow de Pipeline de Dados
# .github/workflows/data-pipeline.yml
name: Deploy Data Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # OBRIGATÓRIO: permite gerar o JWT OIDC
contents: read
jobs:
# ── Validação em PRs (role readonly) ──────────────────────────────
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assume role de validação (readonly)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-oidc-data-readonly
aws-region: sa-east-1
role-session-name: pr-validation-${{ github.run_id }}
- name: Valida estrutura do Data Lake
run: |
aws s3 ls s3://empresa-datalake-prod/raw/ --recursive | head -20
aws glue get-job --job-name etl-vendas
# ── Deploy em push para main (role com permissões reais) ───────────
deploy:
runs-on: ubuntu-latest
needs: validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Assume role de deploy
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-oidc-data-deploy
aws-region: sa-east-1
role-session-name: deploy-${{ github.run_id }}-${{ github.sha }}
- name: Login no ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build e push da imagem do pipeline
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/data-pipeline:$IMAGE_TAG .
docker push $ECR_REGISTRY/data-pipeline:$IMAGE_TAG
docker tag $ECR_REGISTRY/data-pipeline:$IMAGE_TAG \
$ECR_REGISTRY/data-pipeline:latest
docker push $ECR_REGISTRY/data-pipeline:latest
- name: Sincroniza scripts Glue com S3
run: |
aws s3 sync ./glue_jobs/ s3://empresa-artifacts/glue-scripts/ \
--exclude "*.pyc" \
--delete
- name: Dispara job Glue de ETL
run: |
JOB_RUN_ID=$(aws glue start-job-run \
--job-name etl-vendas \
--arguments '{"--env":"prod","--date":"'"$(date +%Y-%m-%d)"'"}' \
--query 'JobRunId' --output text)
echo "Job iniciado: $JOB_RUN_ID"
# Aguarda conclusão
while true; do
STATUS=$(aws glue get-job-run \
--job-name etl-vendas \
--run-id $JOB_RUN_ID \
--query 'JobRun.JobRunState' --output text)
echo "Status: $STATUS"
if [[ "$STATUS" == "SUCCEEDED" ]]; then
echo "✅ ETL concluído com sucesso"
break
elif [[ "$STATUS" == "FAILED" || "$STATUS" == "ERROR" ]]; then
echo "❌ ETL falhou"
exit 1
fi
sleep 30
done
## Outros provedores OIDC - GitLab, CircleCI
# GitLab CI
resource "aws_iam_openid_connect_provider" "gitlab" {
url = "https://gitlab.com"
# Padronizando o audience para STS, seguindo as melhores práticas atuais do GitLab id_tokens
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["..."]
}
resource "aws_iam_role" "gitlab_deploy" {
name = "gitlab-oidc-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.gitlab.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
# sub do GitLab: project_path:grupo/repo:ref_type:branch:ref:main
"gitlab.com:sub" = "project_path:empresa/data-platform:ref_type:branch:ref:main"
}
}
}]
})
}
## Python - Usando STS diretamente (cross-account)
import boto3
from datetime import datetime
def assumir_role_dados_prod(account_id: str, role_name: str) -> dict:
"""
Assume uma role em outra conta AWS para acessar dados de produção.
Útil em pipelines que rodam na conta-dev e precisam ler da conta-prod.
"""
sts = boto3.client('sts')
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
session_name = f"pipeline-{datetime.now().strftime('%Y%m%d%H%M%S')}"
response = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=session_name,
DurationSeconds=3600, # 1 hora (padrão)
# Tags que ficam registradas no CloudTrail
Tags=[
{'Key': 'Pipeline', 'Value': 'etl-vendas'},
{'Key': 'Environment', 'Value': 'prod'}
]
)
creds = response['Credentials']
print(f"✅ Role assumida. Expira em: {creds['Expiration']}")
return creds
def criar_cliente_cross_account(service: str, account_id: str, role_name: str):
"""
Cria um cliente boto3 autenticado na conta de destino.
Padrão usado em pipelines multi-conta.
"""
creds = assumir_role_dados_prod(account_id, role_name)
return boto3.client(
service,
aws_access_key_id = creds['AccessKeyId'],
aws_secret_access_key = creds['SecretAccessKey'],
aws_session_token = creds['SessionToken'],
region_name = 'sa-east-1'
)
# Uso em pipeline cross-account
s3_prod = criar_cliente_cross_account(
service = 's3',
account_id = '111122223333', # conta de produção
role_name = 'DataReadRole'
)
# Agora lê dados de produção com credenciais temporárias
response = s3_prod.list_objects_v2(
Bucket='empresa-datalake-prod',
Prefix='refined/vendas/'
)
arquivos = [obj['Key'] for obj in response.get('Contents', [])]
print(f"Arquivos encontrados: {len(arquivos)}")
6. COMO CAI EM CERTIFICAÇÕES AWS
Tabela de conceitos-chave

Pegadinhas clássicas:
id-token: writeé obrigatório no GitHub Actions
# SEM isso, o workflow não consegue gerar o JWT OIDC
permissions:
id-token: write ← OBRIGATÓRIO
contents: read
- O problema do “confused deputy”
Cenário: Empresa A configura Role que confia em Empresa B (parceiro).
Problema: Empresa B pode usar a Role para acessar dados de Empresa C
(outra cliente da B que também usa AWS).
Solução: ExternalId - um segredo combinado entre A e B.
A Role só aceita assume se B enviar o ExternalId correto.
resource "aws_iam_role" "parceiro" {
assume_role_policy = jsonencode({
...
Condition = {
StringEquals = {
"sts:ExternalId" = "segredo-combinado-123"
}
}
})
}
Nota de Arquitetura: O
ExternalIdresolve o Confused Deputy no cenário tradicional entre contas AWS (sts:AssumeRole). Quando usamos OIDC (sts:AssumeRoleWithWebIdentity), quem faz exatamente esse papel de nos proteger contra o Confused Deputy são as claimssub(identidade precisa do repositório) eaud(audiência), que amarramos na Condition da Trust Policy.
- Duração máxima de sessão configurável na Role
Padrão: 1 hora
Mínimo: 15 minutos
Máximo configurável: até 12 horas (define na Role, não na chamada)
Na prova: "pipeline longo precisa de mais de 1h de credenciais"
→ Aumente o MaxSessionDuration da Role (até 12h)
→ Ou redesenhe para renovar credenciais no meio do processo
- Sub-claim do OIDC é o que protege contra abuso
sub = "repo:empresa/app:ref:refs/heads/main"
Se você colocar StringLike com "*" no sub,
qualquer repositório poderia assumir sua role!
Sempre seja específico:
✅ "repo:minha-empresa/meu-repo:ref:refs/heads/main"
❌ "repo:*"
❌ Sem Condition nenhuma
- STS credentials têm 3 partes - as 3 são obrigatórias
# ERRADO: usar só AccessKey e SecretKey de credencial temporária
boto3.client('s3',
aws_access_key_id='ASIA...',
aws_secret_access_key='...'
# ← faltou SessionToken → vai dar InvalidClientTokenId
)
# CERTO:
boto3.client('s3',
aws_access_key_id='ASIA...',
aws_secret_access_key='...',
aws_session_token='...' ← OBRIGATÓRIO para creds temporárias
)
7. MAPA MENTAL
STS + OIDC
├── STS (Security Token Service)
│ ├── Emite credenciais temporárias (AccessKey + Secret + Token + Expiration)
│ ├── AssumeRole → delegação interna ou cross-account
│ ├── AssumeRoleWithWebIdentity → OIDC/JWT (GitHub, mobile)
│ ├── AssumeRoleWithSAML → SSO corporativo (Okta, Azure AD)
│ └── GetSessionToken → humano com MFA
│
├── OIDC (OpenID Connect)
│ ├── Protocolo padrão de identidade baseado em JWT
│ ├── Provedores: GitHub, GitLab, CircleCI, Google, etc.
│ ├── IAM OIDC Provider = confiança registrada na conta AWS
│ └── Sub-claim = identidade precisa (repo + branch + org)
│
├── TRUST POLICY (quem pode assumir a role)
│ ├── Principal: Federated (OIDC) ou AWS (conta/role)
│ ├── Action: sts:AssumeRoleWithWebIdentity
│ └── Condition: StringEquals/StringLike nas claims do JWT
│
└── BOAS PRÁTICAS
├── Nunca chaves estáticas em CI/CD → sempre OIDC
├── Roles separadas por contexto (deploy ≠ readonly ≠ infra)
├── ExternalId para parceiros externos
├── Condition no sub-claim sempre específico
└── Session name descritivo para auditoria no CloudTrail
8. FLASHCARDS
Por que usar OIDC em vez de chaves IAM no GitHub Actions? Chaves IAM são permanentes e podem vazar. OIDC gera credenciais temporárias por execução - sem segredo armazenado, sem rotação manual, sem risco de vazamento em logs.
O que é o sub-claim no JWT OIDC do GitHub?
É a identidade precisa do executor: repo:org/repo:ref:refs/heads/main. É o que a Condition da trust policy valida para garantir que só o repositório certo pode assumir a role.
Qual a diferença entre AssumeRole e AssumeRoleWithWebIdentity?
*AssumeRole usa credenciais AWS para delegar acesso (same account ou cross-account). AssumeRoleWithWebIdentity usa um JWT externo (OIDC) - não precisa de credenciais AWS para iniciar o fluxo.*
O que é ExternalId e quando usar?
- É um segredo combinado entre você e um parceiro externo para evitar o “confused deputy attack”. Use sempre que uma empresa terceira vai assumir uma role na sua conta.*
9. PENSE COMO ENGENHEIRO
Cenários reais de decisão
Problema 1 - “Troubleshooting: GitHub Actions com erro InvalidIdentityToken .”
Causas mais comuns:
1. permissions.id-token: write não está no workflow
2. O sub-claim na trust policy não bate com o repositório real
(verifique: org/repo, branch, se é PR ou push)
3. O OIDC Provider não foi criado na conta correta
4. Audience errado (deve ser "sts.amazonaws.com")
Debug:
# No workflow, adicione um step para ver o token decodificado
- name: Debug OIDC token
run: |
curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | \
python3 -c "import sys,json,base64; t=json.load(sys.stdin)['value'];
h,p,s=t.split('.'); print(json.dumps(json.loads(
base64.b64decode(p+'==').decode()), indent=2))"
Problema 2 - Design: “Como dar acesso ao Glue em conta de produção a partir da conta de CI/CD?”

Problema 3 - Segurança: “Como garantir que deploy só roda de commits aprovados?”
# Adiciona proteção de environment no GitHub
deploy:
environment: production # ← requer aprovação manual
if: github.ref == 'refs/heads/main'
steps:
- name: Assume role de deploy
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
# O sub-claim já garante que é branch main
# O environment garante aprovação humana
## Trade-offs importantes:

10. EXERCÍCIOS
## Quiz Rápido
1. Um workflow do GitHub Actions falha com Not authorized to perform sts:AssumeRoleWithWebIdentity. Qual é a causa mais provável?
- a) A role não tem permissão para acessar S3
- b) A trust policy da role não tem o sub-claim do repositório correto ✅
- c) O STS está fora do ar na região
- d) Falta a policy
AmazonSTSFullAccess
2. Qual a diferença entre AssumeRole e AssumeRoleWithWebIdentity?
- a) Nenhuma - são sinônimos
- b)
AssumeRoleusa credenciais AWS como entrada;AssumeRoleWithWebIdentityusa um JWT externo ✅ - c)
AssumeRoleWithWebIdentitysó funciona com Google - d)
AssumeRolenão funciona cross-account
3. Um parceiro externo vai assumir uma role na sua conta. Como proteger contra confused deputy attack?
- a) Criar um IAM User separado para o parceiro
- b) Usar
ExternalIdna Condition da trust policy ✅ - c) Ativar MFA na role
- d) Usar SCPs
## Lab Prático
Lab - Pipeline de dados com OIDC end-to-end:
1. Crie um repositório no GitHub com um workflow simples
2. No Terraform, configure o OIDC Provider para GitHub
3. Crie uma Role com trust policy específica pro seu repo/main
4. Dê permissão: s3:ListBucket no seu bucket de teste
5. No workflow, use aws-actions/configure-aws-credentials
6. Step de teste: aws s3 ls s3://seu-bucket → deve funcionar ✅
7. Troque o branch na Condition por "refs/heads/outra-branch"
8. Faça push em main → deve falhar ✅ (branch não bate)
9. Adicione um segundo job com role readonly para PRs
10. Abra um PR → valide que só o job readonly roda ✅
Próximo Passo (Parte 5)
→ AWS KMS - criptografia de ponta a ponta (a última camada de proteção dos seus dados) - Em breve
메타데이터
- post_id
- e31fb3e10d26
- slug
- aws-para-engenheiros-de-dados-parte-4-aws-sts-oidc-autenticação-federada-e-ci-cd-e31fb3e10d26
- url
- https://medium.com/@rafa-trindade/aws-para-engenheiros-de-dados-parte-4-aws-sts-oidc-autentica%C3%A7%C3%A3o-federada-e-ci-cd-e31fb3e10d26
- canonical_url
- https://medium.com/@rafa-trindade/aws-para-engenheiros-de-dados-parte-4-aws-sts-oidc-autentica%C3%A7%C3%A3o-federada-e-ci-cd-e31fb3e10d26
- author_url
- https://medium.com/@rafa-trindade
- status
- ok
- fetched_at
- 2026-08-02 11:33:19