Introduction
Almost in our every day internet connected life, secure authentication is crucial. JWT (JSON Web Token) has become a mainstay in modern authentication, providing stateless, portable and cryptographically secured data exchange between web application, APIs and Single Sign-On systems. But their use comes with some risks, i.e JWT misuse
or an insecure implementation
can make JWTs a vector for attack. This blog breaks down the fundamental concepts of the JWTs, where he talks about the basic workflow of JWT and the common security pitfalls and fix with the help of the hands-on labs, code walks through. Whether you’re building something with JWTs or auditing security systems as a security enthusiast, these principles are important to protecting digital identities.
Introduction to JSON Web Tokens (JWT): Core Concepts and Workflow
What Is a JWT?
A JSON Web Token (JWT) is like a digital passport used to verify a user’s identity and securely exchange information between parties. Against saving session data, everything that required to authenticate end user and pass permissions, stored within itself in the token. It is widely used in the following areas:
Web applications (for session management)
RESTful APIs (for stateless authentication)
Single Sign-On (SSO) systems
Why Should we Consider Using JWT?
There are many reasons why modern developers use jwt
but below are top three reasons :
Statelessness
Since JWTs carry all their own data, your server doesn’t need to track session state. You simply verify the token on each request.
Security
Every JWT is digitally signed. The signature ensures the token hasn’t been tampered with in transit.
Portability
Tokens are compact and URL-safe. You can pass them in HTTP headers, cookies, or even query strings.
Understanding JWT Structure: Three Building Blocks
A JWT is composed of three Base64Url-encoded parts, separated by dots (.
). Each part plays a distinct role:
Now let’s go over each of these with the aid of a table. Don’t mix this up with Base64URL encoding
and HMACSHA256
, and actually, the above diagram, is really does mean that the Header
and Payload
ends up being Base64Url encoded
, concatenated
, then run through HMAC-SHA256
(on the secret) to get the Signature
Part | What It Is | Example Snippet |
Header | Metadata about the token and signing algorithm | {"alg":"HS256","typ":"JWT"} |
Payload | The “claims” or data you want to share (e.g., user ID, roles) | {"sub":"1234567890","name":"John Doe","admin":true} |
Signature | A hash of header + payload using a secret key to ensure integrity | HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) |
Example JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // Header: {"alg":"HS256","typ":"JWT"}
eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlfQ. // Payload: {"sub":"123","name":"John","admin":true}
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature: HMACSHA256(header.payload, secret)
How JWT Works: Step-by-Step Illustration
Below is the typical flow for using JWTs in an application:
1. User Login
└─ User submits credentials (e.g., username/password).
2. Server Verification
└─ Server checks credentials. If valid, it proceeds to token creation.
3. JWT Generation
├─ Server builds:
│ • Header: algorithm & token type
│ • Payload: user claims (e.g., user ID, roles)
│ • Signature: HMAC-SHA256(header + payload, secret key)
└─ All three parts are Base64Url-encoded and concatenated.
4. Token Storage
└─ The JWT is sent to the client (often via a cookie or in the response body).
5. Subsequent Requests
└─ Client attaches the JWT (e.g., in the `Authorization: Bearer <token>` header).
6. Token Verification
└─ Server decodes the header & payload, re-computes the signature using its secret, and compares it.
If they match, the token is valid.
7. Access Granted
└─ Server processes the request based on the claims in the payload.
The whole 7 step JWT authentication process can be understood better visually using the following diagram:
Security Note: Protect the Secret Key
Often the signature is only as strong as the secret key used to generate it. If this key is discovered by an attacker - for example if someone found out that it was hardcoded into the clientweb code - then they could generate their own tokens (by changing "admin":false
to "admin":true
). Always keep your signing secrets securely on the server side!
BlackBox Testing
Field | Details |
Lab Name | Lab: JWT authentication bypass via unverified signature |
Lab URL | Visit Lab |
Credentials | wiener:peter |
At first, we can login to the lab, and you can view the requests on your burp suite. Make sure you grab your JWT
token from the input requests.
You can understand each part of the JWT token from websites like https://jwt.io
But there is one issue, you can’t change the values, for that you have to send the request which has the cookie to the repeater using CTRL + R
and then remember the fact that JWT token has three parts each separated by a dot? Highlight the second part with mouse and edit the payload part that contains sub and change the sub from wiener
to administrator
In the inspector tab, make sure you click on apply changes.
Once the changes are applied, copy the cookie
and for some reasons chrome based browsers don’t go well with cookie attacks. It’s best to use some browser like firefox. Once you have successfully added the cookie to the session, then you should see Admin Panel
on your top right corner.
Click on Admin Panel
and delete the user carlos
with this you have solved your lab.
Now in this scenario we have tampered data with JWT token having encoded with RS256
algorithm. Now we can solve other labs from portswigger academy containing different scenarios and play CTFs online that talk about these things. You can start from this youtube playlist, as reference and as part of solution when you get stuck solving JWT labs from portswigger.
WhiteBox Testing
Field | Details |
Challenge Name | WayWitch |
Challenge URL | Visit Challenge |
Difficulty | Easy |
Let me know if you want to add difficulty, tags, or a description snippet too.
Overview:
The goal for us is to make the vulnerability clear to anyone with basic cybersecurity knowledge, and then demonstrate the concrete steps of writing a Semgrep rule to catch similar mistakes in JavaScript code.
Diving into the Vulnerable Code
When we inspect the challenge source, the key function is generateJWT
in the client’s JavaScript:
async function generateJWT() {
const existingToken = getCookie("session_token");
if (existingToken) return;
// 1) Build header and payload
const header = { alg: "HS256", typ: "JWT" };
const payload = {
username: "guest_" + Math.floor(Math.random() * 10000),
iat: Math.floor(Date.now() / 1000),
};
// 2) Import the SECRET on the client!
const secretKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode("halloween-secret"), // <-- Hardcoded secret
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
// 3) Sign the data on the client
const dataToSign = `${btoa(JSON.stringify(header))}.${btoa(JSON.stringify(payload))}`;
const signatureBuffer = await crypto.subtle.sign(
{ name: "HMAC" }, secretKey, new TextEncoder().encode(dataToSign)
);
// 4) Assemble the JWT and store it
const jwt = `${dataToSign}.${toBase64(signatureBuffer)}`;
document.cookie = `session_token=${jwt}; path=/`;
}
Breaking It Down the vulnerable Code
What is JWT? Think of JSON Web Tokens (JWTs) as digital ID cards. They contain information (like a username) and a digital stamp (the signature) that proves the server issued it.
What went wrong? Normally, the secret key (the stamp pad) lives on the server so attackers can’t forge tokens. In WayWitch, the code brings that secret ("halloween-secret"
) into the browser. That’s like handing out your stamp pad to everyone.
Why is that a problem? With the secret, anyone can stamp their own JWT claiming they’re an admin. They just change the username in the payload to admin
, re‑sign it in their browser, and the server trusts it.
Translating the Flaw into Semgrep Patterns
Step 1: Identify Unique Code Patterns
Key code constructs include:
crypto.subtle.importKey("raw", new TextEncoder().encode(<literal>), {...}, ..., ["sign"])
(MDN Web Docs)
Calls to popular libraries like jwt.sign(payload, secret, options)
(Semgrep)
Step 2: Craft Basic Semgrep Syntax
A Semgrep rule skeleton defines metadata and which languages to scan:
rules:
- id: client-side-jwt-key-exposure
severity: ERROR
languages: [javascript, typescript]
Semgrep patterns use metavariables (e.g., $SECRET_STRING
) to match any literal (Semgrep).
Step 3: Build Flexible, “Pattern-Either” Rules
To capture variations, we employ pattern-either
, matching any one of several patterns (Semgrep):
a. Direct hardcoded string in jwt.verify
- pattern: jwt.verify($A, "...")
Matches any call to jwt.verify()
where a literal string is passed as the verification secret, revealing the key in application code. By catching this, we prevent attackers from discovering the secret needed to forge or tamper with tokens (Auth0).
b. Variable assigned to string literal then used in jwt.verify
- patterns:
- pattern: const $SECRET = "...";
- pattern: jwt.verify($A, $SECRET)
Flags cases where a secret is first hard-coded into a variable and then supplied to jwt.verify()
, which still exposes the key in source. This two-step detection covers codebases that separate declaration and usage for a false sense of security (Stack Overflow).
c. Direct hardcoded string in jwt.sign
- pattern: jwt.sign($A, "...")
Detects calls to jwt.sign()
with a literal secret argument, indicating that token signing keys are embedded in client or server code. Exposing the signing secret in this way lets attackers craft valid tokens with arbitrary payloads (JSON Web Tokens - jwt.io).
- Variable assigned to string literal then used in
jwt.sign
- patterns:
- pattern: const $SECRET = "...";
- pattern: jwt.sign($A, $SECRET)
Catches patterns where the signing secret is stored in a variable before being passed to jwt.sign()
, which doesn’t mitigate exposure. By matching both the variable declaration and its use, we ensure no hard-coded keys slip through under the guise of indirection (geeksforgeeks.org).
Step 4: Add Edge-Case Patterns
We also match token storage in cookies when a secret appears nearby, catching ad hoc implementations.
Enriching the Rule with Metadata
Let’s add some metadata about few owasp
& cwe
rules so that it would be helpful in real world assessments and engagements during web application testing.
metadata:
cwe:
- "CWE-321"
- "CWE-798"
owasp:
- "A02:2021"
- "A07:2021"
references:
- "https://semgrep.dev/blog/2020/hardcoded-secrets-unverified-tokens-and-other-common-jwt-mistakes/"
- "https://owasp.org/Top10/A02_2021-Cryptographic_Failures/"
Not to mention a concise message guides developers to the root cause and fix would be even more helpful
message: |
Hard-coded JWT signing key detected in client code.
This exposes your secret to attackers, who can then forge tokens and bypass authentication.
Always keep signing keys on the server side and fetch tokens via secure API calls.
Step5 : Testing & Cleanup
Positive tests: Code samples with hard-coded secrets should trigger the rule.
Negative tests: Safe code—e.g., fetching keys from a secured endpoint or using crypto.subtle.generateKey
—must not match.
Iterative refinement: Monitor false positives/negatives in CI and adjust patterns or add pattern-not
clauses as needed.
Final Semgrep Rule YAML
Our final yaml
rule should look something like the following:
rules:
- id: jwt-hardcoded-secret-comprehensive
pattern-either:
# Direct hardcoded string in jwt.verify
- pattern: jwt.verify($A, "...")
# Variable assigned to string literal then used in jwt.verify
- patterns:
- pattern: const $SECRET = "...";
- pattern: jwt.verify($A, $SECRET)
# Same patterns but for jwt.sign
- pattern: jwt.sign($A, "...")
- patterns:
- pattern: const $SECRET = "...";
- pattern: jwt.sign($A, $SECRET)
message: "Hardcoded JWT secret detected. Store secrets in environment variables, not in code."
metadata:
cwe:
- "CWE-321: Use of Hard-coded Cryptographic Key"
- "CWE-798: Use of Hard-coded Credentials"
owasp:
- "A02:2021 - Cryptographic Failures"
severity: ERROR
languages:
- javascript
- typescript
Let’s save this file and execute to see if we are able to catch this error.
┌──(kali㉿kali)-[~/Downloads/src]
└─$ semgrep --config jwt-second-client.yaml util.js
┌──── ○○○ ────┐
│ Semgrep CLI │
└─────────────┘
Conclusion
JWTs aren’t broken or insecure protocols, we just implement them badly. A few careful steps separate “secure auth” from “$50k oopsie”. When avoiding hardcoded secrets, to decisively authenticate signatures, minor oversights can cause a major problem. The practical examples and Semgrep rules featured in this article highlight just how critical and proactive code reviews are. Keep dabbling in resources such as PortSwigger’s Web Security Academy and remain on your toes , because in the world of cybersecurity, every token has a story to tell, and it’s up to you to make sure it isn’t a cautionary tale !