01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-09 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 2, Token Engineering |
| Difficulty | Intermediate |
| Estimated Time | 3 to 4 hours |
| Core Technologies | JWT (RFC 7519), JWS (RFC 7515), JWE (RFC 7516), JWK/JWKS (RFC 7517), JWA (RFC 7518), Python 3.11, jwcrypto 1.5, openssl, jq, Keycloak 24.x |
| Builds On | Lab 02 (Keycloak realm and OIDC token issuance), Lab 04 (authorization code with PKCE, the flow that produced the tokens examined here), Lab 06 (HA cluster serving the JWKS endpoint) |
| Feeds Into | Lab 10 (OAuth 2.1 hardening, DPoP and PAR), Lab 11 (token exchange, RFC 8693), Lab 12 (session management) |
02Lab Title and Description
JWT, JWS and JWE Deep Dive
Throughout Phase 1, Keycloak issued access tokens, ID tokens and refresh tokens to the hr-portal client without you ever needing to look inside them. In Phase 2 you become responsible for the tokens themselves, and the first requirement of that responsibility is understanding precisely what a token is, what protects it, and what it does not protect. A common and costly misconception at Northgate Financial, repeated in a recent internal design review, was that a signed JSON Web Token is confidential. It is not. A standard signed access token is readable by anyone who holds it, and treating it as a place to store secrets is a real vulnerability.
In this lab you obtain a genuine access token from your running Keycloak realm, decode its three parts by hand to see there is no encryption involved, and then verify its signature cryptographically against the realm's published JSON Web Key Set (JWKS). You will see how the kid header links a token to a specific public key, why that matters for key rotation, and how a verifier fetches keys it has never seen before. You will then construct a JSON Web Encryption (JWE) object, the standard that does provide confidentiality, and nest a signed token inside it, understanding when that nested structure is warranted and when it is unnecessary overhead.
By the end you will be able to read any JWT on sight, explain the difference between signing and encryption to a colleague who has conflated them, and make an informed architectural decision about which protection a given token actually needs.
Estimated completion time: 3 to 4 hours, including hands-on token decoding, signature verification and JWE construction.
03Prerequisites
Completed Prior Labs
| Lab | Why it is required |
|---|---|
| IB-SIA-02, Keycloak realm and OIDC federation | Provides the northgate realm and the hr-portal client whose tokens you decode and verify in this lab. |
| IB-SIA-04, Authorization code with PKCE | Establishes the authorization code flow used to obtain a real access token. This lab reuses that flow, or the simpler direct grant, to get a token to examine. |
| IB-SIA-06, Keycloak High Availability | The realm's JWKS endpoint, from which you fetch verification keys, is served through the ib-lb load balancer. Understanding that the same keys are served from both nodes matters when you reason about key rotation later in Phase 2. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 6 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this; this lab adds only lightweight command-line tools) |
| Disk | 2 GB free |
| CPU | 2 cores sufficient |
| Network | Access to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI |
Required Tools
| Tool | Exact Version |
|---|---|
| Python | 3.11.x |
| jwcrypto | 1.5.6 (installed via pip in Step 1.1) |
| openssl | 3.x |
| curl | 8.x |
| jq | 1.7 |
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip openssl jq curl
# Verify
python3.11 --version # Expect: Python 3.11.x
openssl version # Expect: OpenSSL 3.x
jq --version # Expect: jq-1.7 or later
Install and verify: macOS
brew install python@3.11 openssl@3 jq
python3.11 --version
openssl version
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then follow the Ubuntu/Debian instructions above.
04Real World Problem Statement
Tokens are the currency of every modern access decision at Northgate Financial. An engineer who does not understand what a token protects will, sooner or later, make a decision that leaks data or trusts a token they should have rejected. This lab replaces vague familiarity with precise, demonstrable understanding of signing versus encryption.
Risk
Treating a signed-only JWT as confidential leads engineers to place sensitive data, national insurance numbers, internal role hierarchies, in token claims that anyone holding the token can read. This is one of the most common token design mistakes in enterprise systems.
Compliance
Under UK GDPR, personal data placed in a bearer token that traverses browsers, proxies and logs may be exposed far beyond its intended audience. Understanding that a JWS is readable is a prerequisite for lawful data minimisation in token design.
Productivity
Engineers who can decode and verify a token from the command line diagnose authentication failures in minutes rather than escalating them. A malformed aud claim or an expired token is obvious once you can read the token confidently.
Security Posture
Signature verification against a published JWKS, including correct handling of the kid header and the alg field, is the foundation of every token-based trust decision. Getting this wrong, for example accepting the none algorithm, has caused real production breaches.
Concrete scenario: During a Northgate design review for a new internal reporting API, a developer proposed embedding each user's full salary band directly in the access token "so the API does not have to look it up." Because Northgate's access tokens are signed but not encrypted, that salary band would have been readable by any browser extension, logging proxy or intermediary that ever handled the token. In this lab you will prove to yourself, by decoding a real token, exactly why that proposal was rejected, and what the correct alternative (a nested JWE, or not putting the data in the token at all) looks like.
05Skills Mapped to Production Solutions
| Skill Learned | Real-World Enterprise Application |
|---|---|
| Decoding the three Base64url segments of a JWS by hand | Diagnosing authentication and authorization failures across any OAuth 2.0 or OIDC deployment without specialist tooling |
Verifying a JWS signature against a JWKS endpoint, resolving the correct key by kid | Implementing token validation in API gateways, resource servers and service meshes that must independently verify tokens they did not issue |
| Distinguishing JWS (integrity and authenticity) from JWE (confidentiality) | Making sound token design decisions and avoiding the widespread error of placing secrets in signed-only tokens |
| Constructing a JWE and a nested signed-then-encrypted token | Protecting sensitive claims in scenarios such as token relay across untrusted intermediaries or claims that must not be visible to the client |
Recognising and rejecting the alg: none and algorithm-confusion attack patterns | Hardening token validation code against two of the best-documented JWT vulnerability classes |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| Keycloak token endpoint | Issues signed access, ID and refresh tokens for the hr-portal client | Keycloak 24.x, RS256 signing | Existing HA cluster from Lab 06, reached via ib-lb | 8443 (via ib-lb) | Realm signing key generated at realm creation in Lab 02; kid stamped on every token |
| JWKS endpoint | Publishes the realm's current public signing keys so any verifier can validate tokens | Keycloak OIDC discovery | Same cluster | 8443 | Path /realms/northgate/protocol/openid-connect/certs |
| Verifier scripts | Decode, verify and encrypt tokens from the command line | Python 3.11, jwcrypto 1.5, openssl | Run locally in a virtual environment, no container required | N/A | Fetches JWKS at runtime; no keys stored locally except the demo JWE keypair you generate |
Data Flow
- Keycloak issues a JWS access token signed with the realm's private RSA key, stamping the signing key's
kidinto the token header.
Why: thekidlets a verifier select the correct public key from a set that may contain several during a key rotation, without which rotation would break every verifier the moment a new key was introduced. - The verifier fetches the JWKS from the realm's certs endpoint and selects the key whose
kidmatches the token header.
Why: fetching public keys dynamically means the verifier never needs the keys distributed to it out of band, and automatically picks up rotated keys. - The verifier checks the signature and then the claims (
iss,aud,exp), pinning the expected algorithm to rejectnoneand algorithm-confusion attempts.
Why: a valid signature only proves the token was not tampered with; the claim checks prove the token was meant for this audience and is still within its lifetime. - For confidentiality, the signed token is wrapped in a JWE, becoming the encrypted payload of a five-part structure.
Why: signing and encryption are separate concerns; nesting a JWS inside a JWE (sign then encrypt) gives both authenticity and confidentiality when a claim genuinely must be hidden from the token holder.
Security Considerations
| Concern | Lab Approach |
|---|---|
| Algorithm pinning | The verifier explicitly lists the acceptable algorithm (RS256) rather than trusting the token's own alg header, defeating the alg: none and RS256-to-HS256 confusion attacks. |
| Claim validation | Signature verification is always followed by issuer, audience and expiry checks; a valid signature alone is never treated as sufficient. |
| Confidentiality | The lab demonstrates that a JWS payload is plaintext, and reserves JWE for the specific case where a claim must be hidden. |
| Key handling | The demo JWE keypair is generated locally for the lab and deleted at cleanup; the realm signing key is never exported. |
07Step by Step Implementation
Phase 1: Obtain and Decode a Real Token
Step 1.1: Set up the working environment
Create the environment
mkdir -p ~/ib-labs/ib-token-lab
cd ~/ib-labs/ib-token-lab
python3.11 -m venv .venv
source .venv/bin/activate
pip install jwcrypto==1.5.6
python3.11 -c "import jwcrypto; print(jwcrypto.__version__)" and confirm it prints 1.5.6. If the import fails, confirm the virtual environment is active; your shell prompt should show (.venv).
Step 1.2: Obtain an access token from Keycloak
Request a token via the direct grant
# The direct grant (Resource Owner Password Credentials) is used here purely
# for lab convenience to obtain a token without a browser round trip. It is
# deprecated for production use in OAuth 2.1; Lab 04's authorization code
# with PKCE is the correct production flow.
curl -sk -X POST \
"https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
-d "client_id=hr-portal" \
-d "grant_type=password" \
-d "username=asmith" \
-d "password=REPLACE_WITH_ASMITH_PASSWORD" \
-d "scope=openid profile" \
| jq -r '.access_token' > access_token.txt
cat access_token.txt
access_token.txt contains a long string with exactly two dot characters separating three segments. Run tr -cd '.' < access_token.txt | wc -c and expect the output 2. If you get an empty file, the token request failed; run the curl command without the | jq pipe to see the error, which is usually an incorrect password or the direct grant not being enabled on the hr-portal client.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update clients/CLIENT_UUID -r northgate -s directAccessGrantsEnabled=true, substituting the client UUID from Lab 02.
Step 1.3: Decode the three segments by hand
Decode the header and payload with base64
# A helper that adds Base64url padding and decodes a single segment.
# JWT uses Base64url without padding; standard base64 -d needs padding
# restored, and the URL-safe alphabet (-_ instead of +/) translated.
b64url_decode() {
local input="$1"
local pad=$(( 4 - ${#input} % 4 ))
[ $pad -ne 4 ] && input="${input}$(printf '=%.0s' $(seq 1 $pad))"
echo "$input" | tr '_-' '/+' | base64 -d
}
TOKEN="$(cat access_token.txt)"
HEADER="$(echo "$TOKEN" | cut -d. -f1)"
PAYLOAD="$(echo "$TOKEN" | cut -d. -f2)"
echo "=== HEADER ==="
b64url_decode "$HEADER" | jq .
echo "=== PAYLOAD (note: fully readable, no decryption needed) ==="
b64url_decode "$PAYLOAD" | jq .
"alg": "RS256", "typ": "JWT" and a "kid" field. The payload should print readable claims including iss, sub, aud, exp and preferred_username. If jq reports a parse error, the padding helper likely mis-decoded; confirm you copied the b64url_decode function exactly, including the alphabet translation with tr.
Phase 2: Verify the Signature Against the JWKS
Step 2.1: Fetch the realm JWKS
Retrieve and inspect the JWKS
curl -sk \
"https://localhost:8443/realms/northgate/protocol/openid-connect/certs" \
| jq . > jwks.json
# List the key IDs and their intended use
jq '.keys[] | {kid: .kid, use: .use, alg: .alg}' jwks.json
"use": "sig" whose kid matches the kid you saw in the token header in Step 1.3. Keycloak typically also publishes an encryption key ("use": "enc"); the signing key is the one relevant here. If no kid matches, your token may have been issued before a key rotation; obtain a fresh token by repeating Step 1.2.
Step 2.2: Verify the signature with algorithm pinning
verify_token.py
# verify_token.py
# Purpose: verify a Keycloak JWS access token against the realm JWKS,
# pinning the algorithm and checking core claims.
import json
import sys
import time
from jwcrypto import jwt, jwk
EXPECTED_ISS = "https://localhost:8443/realms/northgate"
# The audience your resource server expects. For a Keycloak access token this
# is often the "account" client or your API client id; adjust to match what
# you saw in the aud claim in Step 1.3.
EXPECTED_AUD = "account"
with open("access_token.txt") as f:
token = f.read().strip()
with open("jwks.json") as f:
jwks = jwk.JWKSet.from_json(f.read())
# jwcrypto selects the key from the set by matching the token header kid.
# We pin the algorithm to RS256 explicitly: the verifier decides the
# algorithm, never the attacker-controlled token header.
verified = jwt.JWT(
jwt=token,
key=jwks,
algs=["RS256"],
)
claims = json.loads(verified.claims)
# Signature is valid at this point. Now enforce claim checks that a valid
# signature alone does not guarantee.
now = int(time.time())
errors = []
if claims.get("iss") != EXPECTED_ISS:
errors.append(f"issuer mismatch: {claims.get('iss')}")
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if EXPECTED_AUD not in aud_list:
errors.append(f"audience mismatch: {aud}")
if claims.get("exp", 0) < now:
errors.append("token expired")
if errors:
print("REJECTED:", "; ".join(errors))
sys.exit(1)
print("VERIFIED. Signature valid and claims accepted.")
print(f"Subject: {claims.get('sub')}")
print(f"Username: {claims.get('preferred_username')}")
print(f"Expires in: {claims['exp'] - now} seconds")
Run the verifier
python3.11 verify_token.py
VERIFIED. followed by the subject, username and remaining lifetime. If you see an audience mismatch, update EXPECTED_AUD to match the aud value you observed in Step 1.3; Keycloak's default audience for an access token depends on your client and mapper configuration. If you see a signature error, confirm jwks.json was fetched from the same realm that issued the token.
algs=["RS256"] is not cosmetic. A verifier that instead trusts the token's own alg header can be tricked by an attacker setting alg to none (claiming no signature is needed) or to HS256 (tricking the verifier into using the public key as an HMAC secret). Both are historic, real JWT library vulnerabilities. Always let the verifier, not the token, decide the acceptable algorithms.
Step 2.3: Prove tamper detection
Tamper with a claim and re-verify
# Decode the payload, change a claim, re-encode, and reassemble a token
# with the ORIGINAL signature. Verification must fail.
python3.11 - <<'PYEOF'
import base64, json
def b64url(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64url_dec(s):
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
token = open("access_token.txt").read().strip()
h, p, s = token.split('.')
payload = json.loads(b64url_dec(p))
payload["preferred_username"] = "attacker" # tamper
p_new = b64url(json.dumps(payload).encode())
open("tampered_token.txt", "w").write(f"{h}.{p_new}.{s}")
print("Tampered token written.")
PYEOF
# Now verify the tampered token by temporarily pointing the verifier at it
cp access_token.txt access_token.bak
cp tampered_token.txt access_token.txt
python3.11 verify_token.py || echo "As expected: verification failed on tampered token."
cp access_token.bak access_token.txt # restore original
access_token.txt is restored to the original afterwards.
Phase 3: Add Confidentiality with JWE
Step 3.1: Generate a demo encryption keypair
Generate an RSA keypair with openssl
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out jwe_private.pem
openssl pkey -in jwe_private.pem -pubout -out jwe_public.pem
ls -l jwe_private.pem jwe_public.pem
openssl pkey -in jwe_private.pem -noout -text | head -1 reports a 2048 bit RSA private key. If key generation fails, confirm your openssl is version 3.x with openssl version.
Step 3.2: Encrypt the signed token into a nested JWE
make_jwe.py
# make_jwe.py
# Purpose: take the signed access token (a JWS) and encrypt it into a JWE,
# producing a nested token. The signed token becomes the plaintext, so its
# claims are no longer readable without the recipient's private key.
from jwcrypto import jwk, jwe
with open("access_token.txt") as f:
signed_token = f.read().strip()
with open("jwe_public.pem", "rb") as f:
public_key = jwk.JWK.from_pem(f.read())
# alg RSA-OAEP-256 protects the content encryption key with the recipient's
# public RSA key; enc A256GCM encrypts the actual content with AES-256-GCM,
# an authenticated cipher. cty JWT signals that the plaintext is itself a JWT.
protected_header = {
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"cty": "JWT",
}
token = jwe.JWE(
plaintext=signed_token.encode("utf-8"),
protected=protected_header,
)
token.add_recipient(public_key)
compact = token.serialize(compact=True)
with open("nested_token.txt", "w") as f:
f.write(compact)
parts = compact.split(".")
print(f"JWE created with {len(parts)} parts (compact JWE has 5).")
print("First part (protected header, still Base64url readable):")
import base64, json
hdr = parts[0] + "=" * (-len(parts[0]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(hdr)), indent=2))
print("\nSecond part onward is ciphertext: the claims are no longer readable.")
Run the encryption
python3.11 make_jwe.py
5 parts and print a readable protected header showing RSA-OAEP-256, A256GCM and cty: JWT. Crucially, attempt to decode the second segment of nested_token.txt the way you decoded the payload in Step 1.3; it will not produce readable JSON, because it is now ciphertext.
Step 3.3: Decrypt and recover the original signed token
decrypt_jwe.py
# decrypt_jwe.py
# Purpose: decrypt the nested JWE with the recipient private key and recover
# the original signed JWS, which can then be verified exactly as in Phase 2.
from jwcrypto import jwk, jwe
with open("nested_token.txt") as f:
nested = f.read().strip()
with open("jwe_private.pem", "rb") as f:
private_key = jwk.JWK.from_pem(f.read())
token = jwe.JWE()
token.deserialize(nested, key=private_key)
recovered_jws = token.payload.decode("utf-8")
with open("recovered_token.txt", "w") as f:
f.write(recovered_jws)
print("Decryption succeeded. Recovered inner token matches original:")
print(recovered_jws == open("access_token.txt").read().strip())
Run the decryption
python3.11 decrypt_jwe.py
True, confirming the decrypted inner token is byte-for-byte identical to the original signed token. As a final check, you could point verify_token.py at recovered_token.txt and confirm it still verifies, proving the signature survived the encrypt and decrypt round trip intact.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| Decode without keys | Base64url-decode the payload segment from Step 1.3 | Full claim set is readable, demonstrating a JWS is not confidential |
| Valid signature | Run verify_token.py against a fresh token | Prints VERIFIED with subject and expiry |
| Nested JWE round trip | Run make_jwe.py then decrypt_jwe.py | Recovered token is identical to the original and still verifies |
Negative Tests
| Test | Expected Result |
|---|---|
| Verify the tampered token from Step 2.3 | Verification fails with a signature error before claim checks run |
| Wait for token expiry (Keycloak access tokens default to 5 minutes) then re-run verify_token.py on the original | Signature still valid, but the claim check rejects it as expired |
Change algs=["RS256"] to algs=["none"] in the verifier and re-run | jwcrypto refuses to accept an unsigned token, demonstrating the library's own defence; revert this change afterwards |
| Attempt to Base64url-decode the ciphertext segment of the JWE as JSON | Decoding yields non-JSON bytes, confirming the claims are encrypted |
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Payload decode produces garbled output | Base64url padding not restored, or URL-safe alphabet not translated | Re-check the b64url_decode helper's tr '_-' '/+' and padding logic |
| Signature verification fails on a token you know is valid | jwks.json fetched from a different realm, or after a key rotation | Fetch a fresh token and a fresh JWKS from the same realm in the same session |
| Audience mismatch on an otherwise valid token | EXPECTED_AUD does not match your client and mapper configuration | Set EXPECTED_AUD to the exact aud value observed in Step 1.3 |
| JWE decryption raises an authentication tag error | Wrong private key, or the nested token file was truncated | Confirm jwe_private.pem pairs with the jwe_public.pem used to encrypt, and re-run make_jwe.py |
09Security Analysis
What Makes This Implementation Secure
- The verifier pins the acceptable algorithm to RS256 rather than trusting the token's
algheader, closing thealg: noneand RS256-to-HS256 confusion attack classes. - Signature verification is always followed by explicit issuer, audience and expiry checks, so a valid signature is never mistaken for a valid authorization.
- The JWE uses RSA-OAEP-256 for key wrapping and A256GCM, an authenticated cipher, for content, so tampering with the ciphertext is detected on decryption.
- Public keys are fetched dynamically from the JWKS and matched by
kid, so key rotation does not require redistributing keys to verifiers.
What Is Intentionally Simplified for the Lab
- The direct grant is used to obtain a token quickly; it is deprecated in OAuth 2.1 and Lab 04's authorization code with PKCE is the correct production flow.
- TLS certificate verification against the load balancer is skipped (
curl -sk) becauseib-lbpresents the self-signed certificate from Lab 06. - The JWKS is fetched once and cached in a file; a production verifier fetches it over TLS with an appropriate cache lifetime and refreshes on encountering an unknown
kid. - The demo JWE keypair is unprotected on disk; a production recipient private key would live in an HSM or a secrets manager, as explored in the Phase 4 PKI labs.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| JWKS caching | Cache the JWKS with a bounded lifetime and refresh on an unrecognised kid, rather than fetching on every request or caching indefinitely |
| Algorithm allowlist | Maintain an explicit allowlist of acceptable signing and encryption algorithms centrally, and reject everything else, including none |
| Token contents | Apply data minimisation to claims; never place data in a signed-only token that must not be read by the token holder or intermediaries |
| Clock skew | Allow a small, bounded clock skew (typically 30 to 60 seconds) on exp and nbf checks to tolerate minor time differences between issuer and verifier |
| Key storage | Store encryption private keys in an HSM or managed secrets store, never as plaintext files, as covered in the Phase 4 PKI labs |
10Cleanup
Remove local lab artefacts including generated keys and tokens
cd ~/ib-labs/ib-token-lab
# Remove tokens and the demo JWE keypair. These contain a real (short-lived)
# access token and a private key, so delete them rather than leaving them.
rm -f access_token.txt access_token.bak tampered_token.txt \
recovered_token.txt nested_token.txt jwks.json \
jwe_private.pem jwe_public.pem
deactivate 2>/dev/null || true
Revert the hr-portal direct grant change (only if you enabled it in Step 1.2)
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
clients/CLIENT_UUID -r northgate -s directAccessGrantsEnabled=false
ls ~/ib-labs/ib-token-lab shows only your Python scripts, with no .pem, .txt token or jwks.json files remaining. The Keycloak HA cluster, OpenLDAP and PostgreSQL are untouched by this lab and remain ready for Lab 10.
11Recommended Learning Links
- RFC 7519, JSON Web Token (JWT), IETF
- RFC 7515, JSON Web Signature (JWS), IETF
- RFC 7516, JSON Web Encryption (JWE), IETF
- RFC 7517, JSON Web Key (JWK), IETF
- RFC 7518, JSON Web Algorithms (JWA), IETF
- RFC 8725, JSON Web Token Best Current Practices, IETF
- jwcrypto documentation, Read the Docs
- Keycloak Server Administration Guide, Realm Keys and key rotation, Keycloak documentation
12Portfolio Publishing Guide
Sanitise Before Publishing
This lab handles a real access token and a private key. Confirm none of them reach your repository.
Sanitisation checklist commands
# Confirm no tokens, keys or the password placeholder remain in tracked files
grep -R "REPLACE_WITH" . --include="*.py" --include="*.md" || echo "Clean"
cat >> .gitignore <<'EOF'
*.pem
*_token.txt
access_token*.txt
jwks.json
.venv/
EOF
git status
README for the Repository
README.md skeleton
# IB-SIA-09: JWT, JWS and JWE Deep Dive
Command-line scripts that decode a real Keycloak access token, verify its
JWS signature against the realm JWKS with algorithm pinning, and encrypt
the signed token into a nested JWE for confidentiality.
## Stack
Python 3.11, jwcrypto 1.5, openssl 3, Keycloak 24.x
## What it demonstrates
- A signed JWT payload is plaintext, not confidential
- Correct signature verification with kid resolution and algorithm pinning
- Tamper detection
- Sign-then-encrypt nested JWE round trip
## Part of the Identity Bytes Senior IAM Architect Track
Lab 9 of 36. See identity-bytes.com for the full curriculum.
Git Commands
Commit and push
git add verify_token.py make_jwe.py decrypt_jwe.py README.md .gitignore
git commit -m "IB-SIA-09: JWT/JWS/JWE deep dive with signature verification and nested JWE"
git push origin main
Track Index Line
Add the following line to your master portfolio index:
IB-SIA-09 | JWT, JWS and JWE Deep Dive | Intermediate | JOSE, signature verification, algorithm pinning, nested JWE
LinkedIn Draft
A signed JWT is not a secret. It is a postcard with a wax seal.
This week I went back to first principles and pulled a real access token apart by hand. No library, no debugger, only Base64url decoding at the command line. The entire payload, every claim, was readable in seconds. The signature proves nobody changed it. It does nothing to hide what is inside.
That distinction sounds academic until you watch a design review propose putting a user's salary band directly into a signed access token "so the API does not have to look it up." That data would have been readable by every browser extension, logging proxy and intermediary the token ever touched. Signing is integrity. Encryption is confidentiality. Conflating the two is how sensitive data ends up in places it was never meant to be.
So I did the full loop: decoded the token, verified its signature against the realm JWKS with the algorithm pinned (because letting the token choose its own verification algorithm is how the classic alg:none and HS256 confusion attacks work), proved that tampering breaks it, and then wrapped the signed token in a JWE to add the confidentiality that signing never provided.
When your teams put a claim in a token, does everyone in the room know whether that claim is actually hidden, or only tamper-proof?