This article covers the basics of JSON Web Tokens (JWTs), how they're used for authentication in web applications, and common weaknesses to look for in Capture The Flag (CTF) web exploitation challenges.

Modern web applications use JWTs to manage user authentication and authorization. A JWT is a compact way to transmit information between parties as a JSON object, with a signature to verify it hasn't been tampered with. Understanding how JWTs work and their common weaknesses is essential for solving intermediate web exploitation challenges where authentication bypass or privilege escalation is required.

This is a somewhat advanced topic, and it relies on a solid knowledge of both JSON and Base64 encoding. If you haven't read the previous articles, it would be helpful to start with CTF Basics: Understanding JSON and CTF Basics: Understanding Base64URL Encoding before continuing with this article.

What is a JWT?

A JSON Web Token (JWT) is a string that contains encoded information about a user or their session. JWTs are commonly used for authentication in web applications and APIs. When a user logs in, the server creates a JWT and sends it to the browser. The browser then includes this JWT with every subsequent request to prove the user's identity.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE
3NjkzNDg1MjAsImlhdCI6MTc2OTM0ODIyMCwibmFtZSI6IkF
saWNlIiwicm9sZSI6InVzZXIiLCJzdWIiOiIxMjM0NTY3ODk
wIn0.yk0OfcHvDhRqLZjhe-hojzG4u07MnGJ9LgH09rSjblM

Note that for readability the token has been split into multiple lines.

JWT Structure

At first glance a JWT looks like random characters. However, those familiar with Base64 will recognize Base64URL encoding.

Base64URL is an URL-safe type of Base64 that uses - instead of + and _ instead of /, and doesn't use padding characters (=). This makes JWTs safe to include in URLs without additional encoding. For more on Base64 encoding, see CTF Basics: Understanding Base64 Encoding.

Note that the periods (.) are not part of the Base64URL alphabet. JWTs use periods as separators that divide the token into three distinct Base64URL-encoded parts:

HEADER.PAYLOAD.SIGNATURE

Header

The header contains metadata about the token, specifically the type of token and the algorithm used to sign it. When decoded from Base64URL, it looks like:

{
  "alg": "HS256",
  "typ": "JWT"
}

The alg field specifies the signing algorithm (like HS256, RS256, or none), and typ indicates that the type is a JWT.

Payload

The payload contains the actual data or "claims" about the user. When decoded, it might look like:

{
  "exp": 1769348520,
  "iat": 1769348220,
  "name": "Alice",
  "role": "user",
  "sub": "1234567890"
}

Common fields include:

  • exp: Expiration time (timestamp)
  • iat: Issued at time (timestamp)
  • name: User's name
  • role: User's role or permissions
  • sub: Subject (usually the user ID)

The payload can contain any JSON data the application needs. This means that in addition to the common fields above, it can also contain sub-objects and arrays.

Signature

The signature is used to verify that the token hasn't been tampered with. It's created by taking the encoded header and payload, combining them, and signing them with a secret key using the algorithm specified in the header.

For HS256 (HMAC with SHA-256), the signature is created like:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

How JWTs Work in Authentication

When a user logs in to a web application, they send their credentials (username and password) to the server. The server verifies the credentials and sends the browser a signed JWT containing user information. The browser stores the JWT, and sends it back to the server on every request that follows. The server verifies the JWT signature and extracts user information from the payload, then processes the request based on the user's permissions.

The key idea is that the server doesn't store session information. All necessary data is in the JWT, and the signature proves it hasn't been modified.

It's very important to remember that JWTs are "bearer" tokens. Whoever "bears" ("carries" or "has") the token has all the permissions of the person the token was issued to. JWTs should never be shared, and the ability to access someone else's token can be the goal of a CTF.

Decoding and Encoding JWTs

JWTs are encoded with Base64URL (a URL-safe variant of Base64), but they are not encrypted. Anyone can decode a JWT and read its contents.

Decoding Using jwt.io

The easiest way to decode a JWT is to paste it into a site like jwt.io. This website automatically splits the token into its three parts and displays the decoded header and payload.

Decoding Using the Browser Console

JWTs can be decoded in the Console of the browser developer (F12) tools:

// For readability split this into multiple lines
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE' +
              '3NjkzNDg1MjAsImlhdCI6MTc2OTM0ODIyMCwibmFtZSI6IkF' +
              'saWNlIiwicm9sZSI6InVzZXIiLCJzdWIiOiIxMjM0NTY3ODk' +
              'wIn0.yk0OfcHvDhRqLZjhe-hojzG4u07MnGJ9LgH09rSjblM'

const parts = token.split('.')
const header = JSON.parse(new TextDecoder().decode(
  Uint8Array.fromBase64(parts[0], { alphabet: 'base64url' })))
const payload = JSON.parse(new TextDecoder().decode(
  Uint8Array.fromBase64(parts[1], { alphabet: 'base64url' })))

console.log('Header: ', header)
console.log('Payload: ', payload)

The Uint8Array.fromBase64() function decodes Base64URL directly, and TextDecoder converts the resulting bytes to a string. JSON.parse() then converts the JSON string into a JavaScript object. Note that Uint8Array.fromBase64 is a modern browser feature and may not be available in older browsers.

Decoding Using Python

For command-line work, Python can decode JWTs:

import base64
import json

# For readability split this into multiple lines
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE' +
        '3NjkzNDg1MjAsImlhdCI6MTc2OTM0ODIyMCwibmFtZSI6IkF' +
        'saWNlIiwicm9sZSI6InVzZXIiLCJzdWIiOiIxMjM0NTY3ODk' +
        'wIn0.yk0OfcHvDhRqLZjhe-hojzG4u07MnGJ9LgH09rSjblM'

parts = token.split('.')
header = json.loads(base64.urlsafe_b64decode(parts[0] + '=='))
payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))

print('Header: ', header)
print('Payload: ', payload)

Note the + '==' to add padding. As discussed in the Base64URL article, appending extra == is the Pythonic approach. If the string is already correctly padded, the extra characters are ignored.

Re-encoding JWTs Using Python

After modifying a JWT's header or payload, the parts need to be re-encoded and reassembled. Using Python:

import base64
import json

header = {"alg": "none", "typ": "JWT"}
payload = {"name": "Alice", "role": "admin", "sub": "1234567890"}

def encode_part(data):
    return base64.urlsafe_b64encode(
        json.dumps(data, separators=(',', ':')).encode()
    ).rstrip(b'=').decode()

header_encoded = encode_part(header)
payload_encoded = encode_part(payload)

# The none algorithm has no signature block, but needs the trailing period
token = f"{header_encoded}.{payload_encoded}."
print(token)

The separators=(',', ':') argument removes white space from the JSON output, which is standard for JWTs. The .rstrip(b'=') removes padding since Base64URL in JWTs omits it.

Note that jwt.io also supports re-encoding: paste a token, modify the decoded fields in the interface, and it updates the encoded token in real time.

When the secret is known or has been cracked, a properly signed token can be created instead:

import base64
import hashlib
import hmac
import json

header = {"alg": "HS256", "typ": "JWT"}
payload = {"name": "Alice", "role": "admin", "sub": "1234567890"}
secret = "weakpassword"

def encode_part(data):
    return base64.urlsafe_b64encode(
        json.dumps(data, separators=(',', ':')).encode()
    ).rstrip(b'=').decode()

header_encoded = encode_part(header)
payload_encoded = encode_part(payload)

message = f"{header_encoded}.{payload_encoded}"
signature = base64.urlsafe_b64encode(
    hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
).rstrip(b'=').decode()

token = f"{message}.{signature}"
print(token)

The signature is created by signing the header and payload (joined by a period) with the secret using HMAC-SHA256, then Base64URL-encoding the result.

Common JWT Problems

JWTs are often misconfigured or misused in ways that appear frequently in CTF challenges.

None Algorithm

The JWT specification includes an algorithm called none which means the token is unsigned. This is legitimate in contexts where the token's integrity is guaranteed by other means, but some applications accept "alg": "none" in all cases, effectively skipping signature verification entirely.

To exploit this:

  1. Decode the JWT
  2. Change the header algorithm to "alg": "none"
  3. Modify the payload to escalate privileges (change role to admin, change user ID, etc.)
  4. Remove the signature (everything after the second period)
  5. Re-encode and send the modified token

Weak or Predictable Secret

When JWTs use HMAC algorithms (HS256, HS384, HS512), they're signed with a secret key. If this secret is weak, common, or based on predictable information, it can be recovered and used to sign arbitrary tokens.

Common weak secrets include:

  • secret
  • password
  • key
  • The application name
  • Default values from documentation
  • Timestamps around when the app was created
  • Sequential numbers (1, 12, 123, 1234, etc.)
  • Application name variations

Save the full JWT string to a file and use the hashcat mode 16500 so that it knows it's in the JWT format:

$ hashcat -m 16500 -a 0 token.txt wordlist.txt

Once the secret is recovered, it can be used to sign tokens with any payload.

No Signature Verification

Some applications check that a JWT is present but don't actually verify the signature. This is a critical misconfiguration because it means anyone can create or modify tokens.

To test for this:

  1. Decode the JWT
  2. Modify the payload (change role, user ID, etc.)
  3. Re-encode the header and payload
  4. Add any random string as the signature
  5. Send the modified token

If the application accepts it, the signature isn't being verified.

Algorithm Confusion

Some applications that use RS256 (RSA asymmetric encryption) can be tricked into using HS256 (HMAC symmetric encryption) instead.

RS256 uses a private key to sign and a public key to verify. HS256 uses the same secret for both. If the server can be tricked into verifying an RS256 token as HS256, it can be signed with the public key (which is often available).

This is complex but powerful when it works. The attack requires:

  1. Obtaining the public key
  2. Changing the algorithm in the header from RS256 to HS256
  3. Signing the token with the public key as the HMAC secret

Token Not Required

Removing the JWT (or the entire Authorization header) may reveal how the application handles unauthenticated requests. The application might:

  • Treat unauthenticated requests as a guest user with some permissions
  • Grant access without authentication
  • Have a default user it falls back to

Accessing endpoints without a token is always worth trying.

Sensitive Data in Payload

Remember that JWTs are encoded, not encrypted. Anyone can decode and read the payload. Applications sometimes put sensitive information in JWTs:

  • Passwords
  • API keys
  • Internal system information
  • Flags (in CTF challenges)

JWTs are worth decoding completely, and every field is worth reading carefully.

Finding JWTs in Applications

JWTs can be stored in different locations. Common locations include:

Authorization Header

Most commonly, JWTs are sent in the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Check the Network tab in browser developer tools to see request headers.

Cookies

Some applications store JWTs in cookies:

Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly

Check the Storage tab in browser developer tools (Firefox; the location may vary by browser).

localStorage or sessionStorage

JavaScript might store JWTs in browser storage:

localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...')

Check the Storage tab or use the Console:

console.log(localStorage.token)
console.log(sessionStorage.token)

URL Parameters

Occasionally JWTs are passed in the URL:

https://example.com/api/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Common Hiding Spots for Flags

Flags in CTF challenges involving JWTs turn up in a few predictable places.

Directly in the Payload

The simplest case is a flag stored as a field in the JWT payload:

{
  "flag": "myCTF{jwt_decoded}",
  "role": "user",
  "user": "alice"
}

Solution: Decode the JWT and read the payload.

In an Admin-Only Field

The JWT might contain a flag field that's only populated for admin users:

{
  "role": "user",
  "user": "alice"
}

vs.

{
  "admin_flag": "myCTF{privilege_escalated}",
  "role": "admin", 
  "user": "admin"
}

Solution: Modify the JWT to grant admin privileges, then request the token again or access an admin endpoint.

Requires Specific User ID

The flag might only appear for a specific user:

{
  "role": "user",
  "user": "alice",
  "user_id": "1000"
}

Solution: Try changing the user_id to common values like 0, 1, 2, or specific numbers like 1337.

In the Response After Modification

Sometimes the flag isn't in the JWT itself, but in the server's response after a modified JWT is sent:

{
  "flag": "myCTF{admin_access_granted}",
  "message": "Welcome admin!"
}

Solution: Modify the JWT to escalate privileges, send it to the server, and check the response.

Encoded or Nested

Flags might be Base64-encoded within the payload or nested in unexpected ways:

{
  "data": "bXlDVEZ7bmVzdGVkX2RhdGF9",
  "role": "user",
  "user": "alice"
}

Solution: Decode the value of any suspicious fields. Base64, Base64URL, and others are worth trying.

Summary

JSON Web Tokens (JWTs) are Base64-encoded strings containing three parts: header, payload, and signature. They're used for authentication in modern web applications and APIs. JWTs are encoded but not encrypted, so anyone can decode and read their contents.

Common JWT problems include accepting the none algorithm, weak secrets that can be cracked, no signature verification, algorithm confusion attacks, and not requiring tokens at all. Flags in CTF challenges are often hidden directly in the payload, in admin-only fields, or in server responses after privilege escalation.

JWTs can be decoded using jwt.io, the browser console, or Python. The payload can then be modified to escalate privileges or access restricted data, and re-encoded either with the none algorithm or by signing with a cracked or known secret. Authorization headers, cookies, and browser storage are all worth checking.

Want to learn more about security weaknesses? I'm working through the CWE list and doing writeups for security challenges. Follow along for more articles like this one.