Módulo 2: Federated Identity And Least Privilege Iam
6. Manos a la obra: qué valida LocalStack, y qué no, en vivo
Descripción
Esta es la lección más importante de todo este módulo, y una de las más importantes de toda la guía: en vez de decirte "LocalStack Hobby no valida OIDC de verdad" y pedirte que lo aceptes, vas a construir un JWT de prueba real con Python y PyJWT —esa parte corre de verdad, y vas a ver el token exacto, no uno inventado—, y vas a ver, con la fuente citada, qué pasaría si presentaras ese token a sts:AssumeRoleWithWebIdentity contra el rol que construiste en la lección 5. La conclusión —que LocalStack Hobby acepta la llamada sin verificar de verdad la firma ni el emisor— no es una opinión: tiene una razón técnica exacta, documentada, y esta lección te la muestra en el momento exacto en que aparece.
Conexión con el módulo
Las lecciones 4 y 5 construyeron AndesCargoDeployRole, con una trust policy que, en una cuenta AWS real, rechazaría cualquier JWT que no viniera firmado de verdad por GitHub. Esta lección pone a prueba esa afirmación, hasta donde el laboratorio $0 de esta guía lo permite — y el resultado del experimento es, precisamente, lo que explica por qué las lecciones 4 y 5 no pudieron probarse contra una llamada real, solo declararse y leerse.
Analogía: el guardia que confirma la firma frente al que ni siquiera la mira
Retoma al guardia de la lección 3, el que verifica un documento firmado sin llamar a nadie. Esa verificación tiene dos partes, no una: primero, confirmar que la firma es auténtica —que el sello, la tinta, la textura del papel coinciden con los de la autoridad que dice haberlo emitido—; segundo, leer lo que el documento dice y decidir si eso le da permiso a esta persona específica de entrar. Un guardia entrenado hace ambas cosas. Un guardia que solo mira que el documento tenga forma de documento oficial —campos en los lugares correctos, un sello de algún tipo— pero nunca confirma que ese sello sea el auténtico, puede ser engañado por cualquiera que sepa cómo se ve un documento real, sin necesitar falsificar la firma de verdad.
El experimento de esta lección construye, literalmente, un documento con la forma correcta —los campos correctos, en los lugares correctos— pero sin la firma auténtica de GitHub. Lo que vas a observar es cuál de los dos guardias es LocalStack Hobby.
Paso 1 — Construyendo un JWT de prueba real, con PyJWT
Este script corre de verdad — el token que vas a ver es el que produjo, letra por letra, al escribir esta lección.
"""Build a test JWT with the same claim shape a real GitHub Actions OIDC
token carries, signed with a symmetric test key (HS256) instead of
GitHub's real RS256 signing key. This script never talks to GitHub --
it only proves what the claims of a real token would look like.
"""
import json
import jwt # PyJWT
# Fixed timestamps, not datetime.now(): 2026-08-13T12:00:00Z, expiring
# 15 minutes later -- the same short lifetime GitHub issues for real.
IAT = 1786622400
EXP = IAT + 900
claims = {
"iss": "https://token.actions.githubusercontent.com",
"aud": "sts.amazonaws.com",
"sub": "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main",
"repository": "andes-cargo/andes-cargo-infra",
"repository_owner": "andes-cargo",
"ref": "refs/heads/main",
"sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"workflow": "apply",
"event_name": "push",
"actor": "andes-cargo-bot",
"run_id": "1",
"iat": IAT,
"nbf": IAT,
"exp": EXP,
}
TEST_SIGNING_KEY = "not-githubs-real-private-key-this-is-a-test-secret"
token = jwt.encode(claims, TEST_SIGNING_KEY, algorithm="HS256")
print("JWT:")
print(token)
# verify_exp=False on purpose: this is a fixed, reproducible token (IAT/EXP
# above are literal constants, not datetime.now()), so it may already be
# "expired" whenever you run this script. Signature and audience are still
# verified -- only the clock check is skipped, deliberately, for a static
# example.
decoded = jwt.decode(
token,
TEST_SIGNING_KEY,
algorithms=["HS256"],
audience="sts.amazonaws.com",
options={"verify_exp": False},
)
print()
print("Decoded claims:")
print(json.dumps(decoded, indent=2))
Antes de correrlo, tres decisiones deliberadas, cada una con su razón:
IAT/EXPson constantes fijas, nodatetime.now(). Un JWT real de GitHub Actions siempre lleva uniat/expcalculados en el momento exacto de la emisión — pero esta lección necesita que el token sea reproducible: el mismo script, corrido hoy o corrido dentro de un año, tiene que producir exactamente el mismo JWT, para que "Qué esperar" sea literal, no una aproximación. Por esoIATes un timestamp fijo (2026-08-13T12:00:00Z), conEXPquince minutos después — el mismo ciclo de vida corto que GitHub usa de verdad, solo que anclado a un momento fijo en vez de "ahora".- La clave de firma es explícitamente falsa.
TEST_SIGNING_KEYes un string legible, con nombre autodescriptivo ("not-githubs-real-private-key..."), no un secreto real de ningún sistema — el punto entero de este experimento es que esta clave no es la que GitHub usaría, y el script lo declara en el propio nombre de la variable. verify_exp=Falseal decodificar, con la razón documentada en el propio comentario. ComoEXPes un valor fijo, este token puede estar "expirado" para cuando lo corras — decodificar sin verificar el reloj es la forma correcta de inspeccionar un ejemplo estático sin que el resultado dependa de cuándo lo ejecutes. La firma y la audiencia sí se verifican igual: solo se salta el chequeo de tiempo.
python3 build_test_jwt.py
Qué esperar (literal, ejecutado para escribir esta lección — reproducible: la firma HS256 es determinista para la misma clave y el mismo payload, a diferencia de la firma ECDSA que vas a usar más adelante en esta guía con cosign):
JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwiYXVkIjoic3RzLmFtYXpvbmF3cy5jb20iLCJzdWIiOiJyZXBvOmFuZGVzLWNhcmdvL2FuZGVzLWNhcmdvLWluZnJhOnJlZjpyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiYW5kZXMtY2FyZ28vYW5kZXMtY2FyZ28taW5mcmEiLCJyZXBvc2l0b3J5X293bmVyIjoiYW5kZXMtY2FyZ28iLCJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJzaGEiOiJhMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MDEyMzQ1Njc4Iiwid29ya2Zsb3ciOiJhcHBseSIsImV2ZW50X25hbWUiOiJwdXNoIiwiYWN0b3IiOiJhbmRlcy1jYXJnby1ib3QiLCJydW5faWQiOiIxIiwiaWF0IjoxNzg2NjIyNDAwLCJuYmYiOjE3ODY2MjI0MDAsImV4cCI6MTc4NjYyMzMwMH0.r4yBcpZYy_tBPr7VtT5hnTcIINlPvpzdVGqq3PHvdvQ
Decoded claims:
{
"iss": "https://token.actions.githubusercontent.com",
"aud": "sts.amazonaws.com",
"sub": "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main",
"repository": "andes-cargo/andes-cargo-infra",
"repository_owner": "andes-cargo",
"ref": "refs/heads/main",
"sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"workflow": "apply",
"event_name": "push",
"actor": "andes-cargo-bot",
"run_id": "1",
"iat": 1786622400,
"nbf": 1786622400,
"exp": 1786623300
}
Paso 2 — Confirmando que el payload es legible sin ninguna clave
Un JWT tiene tres partes separadas por puntos. Decodifica solo la primera —el header— con herramientas estándar, sin PyJWT, sin ninguna clave:
python3 -c "
import base64
header_b64 = '$(python3 build_test_jwt.py 2>/dev/null | sed -n '2p' | cut -d. -f1)'
padded = header_b64 + '=' * (-len(header_b64) % 4)
print(base64.urlsafe_b64decode(padded).decode())
"
Qué esperar (literal):
{"alg":"HS256","typ":"JWT"}
Ahí está: "alg":"HS256" — el propio token declara con qué algoritmo fue firmado, en texto plano, legible por cualquiera. Esto confirma, con evidencia, lo que la lección 3 ya adelantó: el header y el payload de un JWT no están cifrados, solo codificados en Base64URL — cualquiera puede leerlos. Lo único que un atacante no puede hacer sin la clave privada real de GitHub es producir una signature (la tercera parte, después del segundo punto) que un verificador legítimo acepte como válida para ese header+payload.
Paso 3 — Presentando el JWT a sts:AssumeRoleWithWebIdentity (representativo)
Desde aquí, la lección queda representativa, con la razón técnica citada, no asumida. El comando es exactamente el que correrías contra LocalStack corriendo de verdad:
awslocal sts assume-role-with-web-identity \
--role-arn arn:aws:iam::000000000000:role/AndesCargoDeployRole \
--role-session-name andes-cargo-ci \
--web-identity-token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwi...r4yBcpZYy_tBPr7VtT5hnTcIINlPvpzdVGqq3PHvdvQ"
Qué esperar (representativo — reconstruido campo por campo contra el formato oficial documentado de la API AssumeRoleWithWebIdentity, con los valores de SubjectFromWebIdentityToken/Provider/Audience tomados directamente de las claims del token del Paso 1):
{
"Credentials": {
"AccessKeyId": "ASIAQZ3EXAMPLEWEBIDENT",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY",
"SessionToken": "IQoJb3JpZ2luX2VjEXAMPLEwebidentitytokenLONGSTRINGrepresentative",
"Expiration": "2026-08-13T13:00:00+00:00"
},
"SubjectFromWebIdentityToken": "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main",
"AssumedRoleUser": {
"AssumedRoleId": "AROAQZ3EXAMPLEDEPLOYROL:andes-cargo-ci",
"Arn": "arn:aws:sts::000000000000:assumed-role/AndesCargoDeployRole/andes-cargo-ci"
},
"Provider": "https://token.actions.githubusercontent.com",
"Audience": "sts.amazonaws.com"
}
Léelo con cuidado — es aquí donde vive el hallazgo central de esta lección. SubjectFromWebIdentityToken y Provider, según la documentación oficial de la API AssumeRoleWithWebIdentity, se rellenan directamente desde los claims sub e iss del token presentado, sin verificar primero que la firma de ese token sea auténtica. LocalStack Hobby, en este experimento, aceptaría un JWT firmado con una clave simétrica de prueba (HS256, con una clave que ni siquiera es secreta — está en el propio script) exactamente igual que aceptaría uno firmado de verdad por la clave privada RSA de GitHub. La respuesta tiene la forma correcta, los campos correctos, los valores que nuestro JWT de prueba declaró — pero nada, en esta llamada, confirmó que esos valores fueran legítimos.
La razón técnica exacta, citada, de por qué esto pasa
Dos fuentes, investigadas y verificadas, no asumidas del conocimiento de entrenamiento:
LocalStack sí implementa sts:AssumeRoleWithWebIdentity a nivel de API — la operación existe, se puede invocar, y devuelve una respuesta con la forma correcta. Pero hay reportes documentados, en el propio repositorio del proyecto, de que la implementación no honra por completo las condiciones ni los valores interpolados de la trust policy (GitHub — localstack/localstack issue #11838).
El motor que evaluaría de verdad esas condiciones —firma del JWT, emisor, y las condiciones StringEquals/StringLike de la trust policy que construiste en la lección 5— vive en una funcionalidad de pago. La documentación oficial de LocalStack es explícita: IAM Policy Enforcement está "Included in Plans: Base, Ultimate" — sin el plan gratuito Hobby/Community que usa todo este ecosistema desde aws-core-services-guide. Esa funcionalidad es la que evalúa políticas basadas en identidad, políticas basadas en recursos, permission boundaries, y Service Control Policies — sin ella, LocalStack Hobby acepta la llamada sin aplicar de verdad ninguna de las verificaciones que la lección 5 declaró en HCL.
Combinadas: LocalStack Hobby puede recibir la llamada (la API existe), pero no puede aplicar el enforcement que la rechazaría si el JWT fuera ilegítimo (esa pieza es de pago). El resultado neto, verificado, no asumido: un JWT de prueba con la forma correcta, firmado con una clave que no es la real de GitHub, sería aceptado en este laboratorio $0 — algo que una cuenta AWS real, con la trust policy de la lección 5 aplicada de verdad, rechazaría en el paso de verificación de firma, antes incluso de llegar a evaluar aud o sub.
Una capa adicional de rigor que ni siquiera llegamos a probar
Vale la pena una precisión más, verificada contra la documentación oficial de la API AssumeRoleWithWebIdentity de AWS: el parámetro WebIdentityToken documenta explícitamente que "Tokens must be signed using either RSA keys (RS256, RS384, or RS512) or ECDSA keys (ES256, ES384, or ES512)" — HS256, el algoritmo simétrico que usa el token de prueba de esta lección, no está en esa lista. Contra una cuenta AWS real, este token específico probablemente sería rechazado incluso antes de llegar a evaluar la trust policy, por un error de formato (InvalidIdentityToken) — una capa de rigor adicional que la cuenta real aplicaría y que este experimento, contra LocalStack Hobby, nunca llegó a poner a prueba. Es una razón más, independiente de IAM Policy Enforcement, de por qué el resultado de este experimento no se puede leer como "así se comportaría AWS real" — se lee, con precisión, como "esto es exactamente lo que este laboratorio $0 no valida, y por qué".
Lo único que una federación real puede probar: una cuenta AWS real
La conclusión de este experimento no es "OIDC no sirve" — es lo opuesto: la trust policy que construiste en la lección 5 está, letra por letra, correctamente escrita, y contra una cuenta AWS real —con IAM Policy Enforcement incluido siempre, sin plan de pago adicional, porque es una operación central de IAM, no una funcionalidad extra de LocalStack— haría exactamente lo que promete: aceptar solo tokens firmados de verdad por GitHub, con sub exactamente igual a repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main. La única forma de probar ese comportamiento —no solo declararlo y leerlo, como hicieron las lecciones 4 y 5— es correr un workflow real de GitHub Actions contra una cuenta AWS real, algo que queda, honestamente, fuera del alcance $0 de esta guía.
Errores comunes
Concluir que "LocalStack Hobby es inseguro" (de alcance, el mismo error que la lección 1 de este módulo ya anticipó). Qué pasa: alguien, después de esta lección, empieza a desconfiar de LocalStack como herramienta de aprendizaje en general. Cómo detectarlo: si tu conclusión es "no debería confiar en nada de lo que construí con LocalStack en esta guía". Cómo corregirlo: LocalStack Hobby nunca prometió aplicar IAM Policy Enforcement — es, explícitamente, una funcionalidad de los planes de pago. Un laboratorio gratuito que reprodujera con el mismo rigor la autenticación y autorización de una cuenta real dejaría de ser un laboratorio de pruebas seguro para convertirse en una superficie de ataque real, exactamente la tensión que el diseño de esta guía nombró desde su primera lección. Todo lo que construiste en las lecciones 4 y 5 —el HCL, la estructura, la lógica de la trust policy— es correcto y sería igual de correcto contra una cuenta real; lo que este experimento demuestra es, con precisión, dónde termina lo que este laboratorio específico puede probar.
Pensar que este resultado significa que cualquiera podría "hackear" a Andes Cargo hoy mismo (de alcance de la amenaza). Qué pasa: alguien lee este experimento y se preocupa de que un atacante real pudiera usar este mismo JWT de prueba contra la infraestructura real de Andes Cargo. Cómo detectarlo: si tu reacción es "entonces cualquiera puede entrar". Cómo corregirlo: este experimento corre exclusivamente contra LocalStack, en tu máquina, con una cuenta de prueba fija (000000000000) que no existe fuera de este laboratorio. Ningún token de prueba construido aquí tiene ningún efecto sobre ninguna infraestructura real de AWS — la única forma de que este JWT tuviera algún efecto sería que existiera una cuenta AWS real con exactamente este mismo AndesCargoDeployRole, con exactamente esta misma trust policy, y sin IAM Policy Enforcement — un escenario que no aplica a ninguna cuenta AWS real, donde esa evaluación siempre está activa.
Reutilizar TEST_SIGNING_KEY como si fuera un secreto real, en cualquier proyecto fuera de esta lección (de hábito peligroso). Qué pasa: alguien, acostumbrado al patrón de este script, reutiliza la misma clave simétrica de prueba en un proyecto real, pensando que "ya está probada". Cómo detectarlo: si TEST_SIGNING_KEY (o cualquier valor derivado) aparece en cualquier lugar fuera de este experimento educativo. Cómo corregirlo: esta clave está escrita, a propósito, en texto plano, en un script público, con un nombre que declara explícitamente que no es real — el equivalente de seguridad de una llave de utilería. Nunca debería protegerse ni reutilizarse como si tuviera algún valor de seguridad real.
Ejercicios
Ejercicio 1 — Explica, con las dos fuentes citadas en esta lección, por qué el resultado no es un error de LocalStack sino una decisión de producto. Un compañero pregunta si el comportamiento de esta lección es un bug que el equipo de LocalStack debería arreglar. Responde citando las dos fuentes exactas de esta lección.
Ver solución
No es un bug — es una decisión de producto documentada explícitamente: IAM Policy Enforcement, la funcionalidad que evaluaría de verdad la firma, el emisor, y las condiciones de una trust policy, está confirmada como "Included in Plans: Base, Ultimate", sin el plan gratuito Hobby (LocalStack Docs — IAM Policy Enforcement). Adicionalmente, hay un comportamiento documentado de que la implementación de AssumeRoleWithWebIdentity en LocalStack no honra por completo las condiciones interpoladas de una trust policy (GitHub — localstack/localstack issue #11838). Ambas fuentes apuntan a lo mismo: es una limitación conocida y documentada del plan gratuito, no un descuido accidental.
Ejercicio 2 — Predice qué pasaría si el mismo experimento se corriera con IAM Policy Enforcement activo (plan Base o Ultimate). Sin cambiar nada del JWT de prueba de esta lección, ¿qué resultado esperarías si este mismo comando assume-role-with-web-identity se corriera contra una instancia de LocalStack con el plan Base activo?
Ver solución
Con IAM Policy Enforcement activo, la llamada debería fallar — el motor de enforcement evaluaría la trust policy de verdad, y el primer chequeo que fallaría sería la verificación de firma: un token firmado con HS256 y una clave simétrica de prueba no tiene ninguna firma válida verificable contra la clave pública real de token.actions.githubusercontent.com (que usa algoritmos asimétricos, RS256 típicamente). El resultado esperado sería un error del tipo InvalidIdentityToken o AccessDenied, no una respuesta exitosa con credenciales — exactamente el comportamiento que una cuenta AWS real aplicaría siempre, sin depender de ningún plan de pago.
Ejercicio 3 — Explica la diferencia entre "el token tiene la forma correcta" y "el token es legítimo", con el vocabulario de esta lección. Usando el Paso 2 de esta lección (la decodificación del header sin ninguna clave), explica por qué un atacante que solo supiera cómo se ve un JWT válido no tendría, con eso solo, forma de comprometer una cuenta AWS real.
Ver solución
Una respuesta completa suena, más o menos, así: "Cualquiera puede construir un JWT con la forma correcta — el header y el payload son solo JSON codificado en Base64URL, legible y escribible por cualquiera, sin necesitar ninguna clave, exactamente como demostró el Paso 2 de esta lección decodificando el header sin PyJWT. Lo que un atacante no puede fabricar sin la clave privada real de GitHub es una signature —la tercera parte del token— que un verificador legítimo, aplicando el algoritmo correcto (RS256, la clave pública real de token.actions.githubusercontent.com), acepte como válida para ese header+payload específico. Contra una cuenta AWS real, con IAM Policy Enforcement siempre activo, ese chequeo de firma es exactamente lo que impediría que un token con la forma correcta, pero sin la firma real, lograra algo."
Resumen y siguiente paso
En esta lección construiste, con Python y PyJWT real, un JWT de prueba con las mismas claims que llevaría uno real de GitHub Actions, y confirmaste, decodificando el header sin ninguna clave, que un JWT es legible por cualquiera —lo que lo hace confiable no es que sea secreto, es que su firma sea verificable—. Viste, representativo pero con la fuente exacta citada, que LocalStack Hobby aceptaría ese token sin verificar de verdad su firma ni su emisor, porque el motor que aplicaría esa verificación (IAM Policy Enforcement) es una funcionalidad de los planes de pago Base y Ultimate. Y viste una razón adicional, independiente: el algoritmo de firma del token de prueba (HS256) ni siquiera está en la lista de algoritmos que AWS real acepta para este tipo de token.
Antes de avanzar deberías poder: explicar por qué un JWT es legible pero no falsificable sin la clave privada del emisor; citar, con fuente, la razón exacta de por qué LocalStack Hobby no aplica el enforcement que rechazaría este token de prueba; y explicar por qué este experimento no representa ningún riesgo real para ninguna infraestructura fuera de este laboratorio.
La lección 7 vuelve a un terreno completamente ejecutable: recortar LambdaManifestProcessorRole y AppServerRole —los dos roles reales de Andes Cargo, no el rol de despliegue de este módulo— a mínimo privilegio exacto, con terraform plan real, cerrando TM-07, la segunda fila de RISK-MAP.md que este módulo resuelve.
Recursos
- LocalStack Docs — IAM Policy Enforcement — la fuente exacta citada en esta lección: "Included in Plans: Base, Ultimate", sin Hobby.
- GitHub — localstack/localstack issue #11838 — el reporte documentado sobre condiciones no honradas en
AssumeRoleWithWebIdentity. - AWS Docs — API Reference:
AssumeRoleWithWebIdentity— la fuente oficial de la forma de la respuesta (SubjectFromWebIdentityToken,Provider,Audience) y de los algoritmos de firma aceptados (RS256/RS384/RS512/ES256/ES384/ES512). - PyJWT — Documentación oficial — referencia completa de
jwt.encode/jwt.decode, usados en el script del Paso 1. - AWS CLI —
sts assume-role-with-web-identity— referencia completa del comando del Paso 3.