IB-SIA-09 Intermediate Est. 3 to 4 hours
Identity Bytes // Senior IAM Architect Track

JWT, JWS and JWE Deep Dive

Pull apart the tokens Keycloak has been issuing throughout Phase 1: decode a real access token, verify its JWS signature against the realm JWKS, then encrypt a nested JWE and understand exactly what each layer protects.

01Lab Metadata

FieldValue
Lab IDIB-SIA-09
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 2, Token Engineering
DifficultyIntermediate
Estimated Time3 to 4 hours
Core TechnologiesJWT (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 OnLab 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 IntoLab 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

LabWhy it is required
IB-SIA-02, Keycloak realm and OIDC federationProvides the northgate realm and the hr-portal client whose tokens you decode and verify in this lab.
IB-SIA-04, Authorization code with PKCEEstablishes 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 AvailabilityThe 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

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM6 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this; this lab adds only lightweight command-line tools)
Disk2 GB free
CPU2 cores sufficient
NetworkAccess to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI

Required Tools

ToolExact Version
Python3.11.x
jwcrypto1.5.6 (installed via pip in Step 1.1)
openssl3.x
curl8.x
jq1.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.
INFO Five RFCs together define this space, collectively called JOSE (JSON Object Signing and Encryption): RFC 7519 (JWT, the claims container), RFC 7515 (JWS, signing), RFC 7516 (JWE, encryption), RFC 7517 (JWK and JWKS, key representation), and RFC 7518 (JWA, the algorithm names such as RS256 and A256GCM). A JWT is not itself signed or encrypted; it becomes a JWS or a JWE depending on which protection is applied.

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 LearnedReal-World Enterprise Application
Decoding the three Base64url segments of a JWS by handDiagnosing 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 kidImplementing 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 tokenProtecting 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 patternsHardening token validation code against two of the best-documented JWT vulnerability classes

06Architecture Overview

JWS COMPACT SERIALISATION: three Base64url parts joined by dots HEADER alg (RS256), typ, kid which key signed this PAYLOAD (CLAIMS) iss, sub, aud, exp, iat READABLE by anyone SIGNATURE RSA sig over header.payload proves integrity, not secrecy KEYCLOAK (via ib-lb) token endpoint issues JWS signs with realm private key RS256, kid on each token JWKS ENDPOINT /realms/northgate/protocol/ openid-connect/certs public keys, matched by kid VERIFIER (this lab) jwcrypto / openssl fetch JWKS, match kid, verify signature and claims NESTED JWE (Phase 3 of this lab): five parts, confidentiality added protected header . encrypted key . IV . ciphertext . auth tag the signed JWS above becomes the plaintext payload, now unreadable without the private key cty: JWT signals a nested token inside 1 issue token 2 fetch keys, verify

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
Keycloak token endpointIssues signed access, ID and refresh tokens for the hr-portal clientKeycloak 24.x, RS256 signingExisting HA cluster from Lab 06, reached via ib-lb8443 (via ib-lb)Realm signing key generated at realm creation in Lab 02; kid stamped on every token
JWKS endpointPublishes the realm's current public signing keys so any verifier can validate tokensKeycloak OIDC discoverySame cluster8443Path /realms/northgate/protocol/openid-connect/certs
Verifier scriptsDecode, verify and encrypt tokens from the command linePython 3.11, jwcrypto 1.5, opensslRun locally in a virtual environment, no container requiredN/AFetches JWKS at runtime; no keys stored locally except the demo JWE keypair you generate

Data Flow

  1. Keycloak issues a JWS access token signed with the realm's private RSA key, stamping the signing key's kid into the token header.
    Why: the kid lets 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.
  2. The verifier fetches the JWKS from the realm's certs endpoint and selects the key whose kid matches 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.
  3. The verifier checks the signature and then the claims (iss, aud, exp), pinning the expected algorithm to reject none and 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.
  4. 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

ConcernLab Approach
Algorithm pinningThe 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 validationSignature verification is always followed by issuer, audience and expiry checks; a valid signature alone is never treated as sufficient.
ConfidentialityThe lab demonstrates that a JWS payload is plaintext, and reserves JWE for the specific case where a claim must be hidden.
Key handlingThe 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

Purpose: create a virtual environment with the JOSE library Context: used for verification in Phase 2 and encryption in Phase 3
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
VERIFICATION Run 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

Purpose: get a genuine, signed token to examine Context: uses the direct grant against the hr-portal client for lab convenience
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
VERIFICATION Confirm 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.
INFO If the hr-portal client does not have direct access grants enabled, enable it for this lab with: 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

Purpose: prove the payload is plaintext, not encrypted Context: this is the core lesson of the lab, done before any tooling
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 .
VERIFICATION The header should print a JSON object containing "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.
SECURITY WARNING You have now read the entire payload without any key or decryption. This is the central point of the lab: a signed JWT is a transparent envelope. Anything you would not print on a postcard does not belong in a signed-only token claim.

Phase 2: Verify the Signature Against the JWKS

Step 2.1: Fetch the realm JWKS

Purpose: retrieve the public keys needed to verify the token Context: the kid in the token header selects which of these keys to use
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
VERIFICATION Expect at least one key with "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

Purpose: cryptographically confirm the token was signed by the realm and is unmodified Context: pins RS256 to defeat alg:none and algorithm-confusion attacks
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
VERIFICATION Expect output beginning 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.
SECURITY WARNING Passing 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

Purpose: demonstrate that signature verification actually catches modification Context: confirms the property you are relying on is real
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
VERIFICATION The verifier must fail on the tampered token, typically raising a signature verification exception before it ever reaches the claim checks. This is the proof that the signature binds the exact payload bytes: changing a single claim invalidates it. Confirm access_token.txt is restored to the original afterwards.

Phase 3: Add Confidentiality with JWE

Step 3.1: Generate a demo encryption keypair

Purpose: create a recipient keypair for the JWE Context: the public key encrypts, the private key decrypts, mirroring how a real recipient would publish an enc key
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
VERIFICATION Confirm both files exist and that 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

Purpose: wrap the readable JWS inside an encrypted JWE so its claims become confidential Context: demonstrates sign-then-encrypt, the recommended nesting order
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
VERIFICATION Expect the script to report 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.
INFO A compact JWE has five dot-separated parts, not three: protected header, encrypted content encryption key, initialisation vector, ciphertext, and authentication tag. Only the first part (the protected header) is readable; everything protecting the actual claims is encrypted.

Step 3.3: Decrypt and recover the original signed token

Purpose: close the loop by decrypting, then re-verifying the recovered JWS Context: shows the recipient gets back exactly the verifiable token that went in
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
VERIFICATION Expect the script to print 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.
What just happened? You stopped treating tokens as opaque strings and started reading them as the structured objects they are. You decoded a real Keycloak access token and saw, with no key at all, that its claims are plaintext: signing protects integrity, not secrecy. You then verified the signature properly, pinning the algorithm so the token cannot dictate how it is checked, and proved that tampering breaks verification. Finally you added the layer that signing does not provide, wrapping the signed token in a JWE so its claims became genuinely confidential, then recovered it intact. You can now look at any token in a log or a browser and know exactly what it is protecting and what it is not.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
Decode without keysBase64url-decode the payload segment from Step 1.3Full claim set is readable, demonstrating a JWS is not confidential
Valid signatureRun verify_token.py against a fresh tokenPrints VERIFIED with subject and expiry
Nested JWE round tripRun make_jwe.py then decrypt_jwe.pyRecovered token is identical to the original and still verifies

Negative Tests

TestExpected Result
Verify the tampered token from Step 2.3Verification 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 originalSignature still valid, but the claim check rejects it as expired
Change algs=["RS256"] to algs=["none"] in the verifier and re-runjwcrypto 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 JSONDecoding yields non-JSON bytes, confirming the claims are encrypted

Common Failure Modes

SymptomLikely CauseResolution
Payload decode produces garbled outputBase64url padding not restored, or URL-safe alphabet not translatedRe-check the b64url_decode helper's tr '_-' '/+' and padding logic
Signature verification fails on a token you know is validjwks.json fetched from a different realm, or after a key rotationFetch a fresh token and a fresh JWKS from the same realm in the same session
Audience mismatch on an otherwise valid tokenEXPECTED_AUD does not match your client and mapper configurationSet EXPECTED_AUD to the exact aud value observed in Step 1.3
JWE decryption raises an authentication tag errorWrong private key, or the nested token file was truncatedConfirm 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

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
JWKS cachingCache the JWKS with a bounded lifetime and refresh on an unrecognised kid, rather than fetching on every request or caching indefinitely
Algorithm allowlistMaintain an explicit allowlist of acceptable signing and encryption algorithms centrally, and reject everything else, including none
Token contentsApply data minimisation to claims; never place data in a signed-only token that must not be read by the token holder or intermediaries
Clock skewAllow 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 storageStore 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
VERIFICATION Confirm 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

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?

Next: IB-SIA-10, OAuth 2.1 Hardening (DPoP and PAR)
With the token internals understood, Lab 10 hardens how tokens are bound and requested: sender-constrained tokens via DPoP, and pushed authorization requests via PAR.