padding: 1rem; }
A practical, in-depth guide to understanding how JSON Web Tokens fail in real applications, how attackers abuse weak implementations, and how developers can build JWT-based authentication securely.

JSON Web Tokens (JWTs) are widely used in modern APIs, web applications, mobile applications, and identity systems. They are particularly common in OAuth 2.0 and OpenID Connect environments, where signed tokens can carry information about an authenticated user, client, issuer, audience, and authorization context.
JWTs are useful, but they are also frequently misunderstood.
The biggest mistake developers make is treating a JWT as if it were a magic authentication mechanism. It isn't. A JWT is a standardized way of representing claims. Whether those claims are trustworthy depends on how the token is created, signed, transported, stored, and validated.
A token can have a perfectly valid cryptographic signature and still be unacceptable to your application.
For example, a token might:
That is why JWT security is not simply a matter of calling verify().
This guide explains the major JWT security problems developers and penetration testers encounter, why they happen, and what a robust implementation should actually validate.
JWT stands for JSON Web Token.
A JWT is a compact, URL-safe representation of claims that can be digitally signed and, in a different form called JWE, encrypted.
A common signed JWT looks like this:
HEADER.PAYLOAD.SIGNATUREFor example:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjMiLCJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsImF1ZCI6ImFwaS5leGFtcGxlLmNvbSIsImV4cCI6MTc2NTU1NTU1NX0
.
SIGNATUREThe three sections have different purposes.
The header contains metadata describing how the token is represented and signed.
A typical header might contain:
{
"alg": "RS256",
"typ": "JWT"
}The alg parameter identifies the cryptographic algorithm used for the JWS signature.
The typ parameter can identify the token type.
Some systems may also use parameters such as kid, which identifies a particular signing key.
Importantly, the header is attacker-controlled input.
Even though the legitimate server creates it, anyone holding a JWT can decode it and create a modified header.
Your application must therefore never assume that a value in the header is trustworthy simply because it appears inside a JWT.
The payload contains claims.
For example:
{
"sub": "123456",
"iss": "https://identity.example.com",
"aud": "https://api.example.com",
"iat": 1765550000,
"exp": 1765550900,
"role": "user"
}Claims can describe things such as:
The payload of an ordinary signed JWT is not encrypted.
The JSON is Base64URL-encoded, which is an encoding mechanism rather than encryption.
Anyone who possesses the token can generally decode the header and payload.
Therefore:
Never treat a signed JWT as a secret container.
Do not place passwords, API keys, private keys, payment-card data, session secrets, or other confidential information inside an ordinary signed JWT.
The signature protects the integrity of the signed token.
Conceptually, the signature is calculated over the encoded header and encoded payload.
For a JWS, the signed input is effectively:
BASE64URL(header) + "." + BASE64URL(payload)The cryptographic signature allows a verifier with the appropriate key to determine whether the signed content was altered.
If an attacker changes:
"role": "user"to:
"role": "admin"the original signature should no longer validate.
That is the fundamental security property provided by a signed JWT.
But there is an important distinction:
A valid signature does not automatically mean the token is valid for your application.
The application must also validate the token's context and claims.
One of the most persistent misconceptions about JWTs is that they hide information.
They don't, unless you are specifically using an encrypted JWT format such as JWE.
Consider:
{
"sub": "123",
"email": "[email protected]",
"role": "admin"
}If this is inside a normal signed JWT, the contents can be decoded by anyone who obtains the token.
The signature protects against unauthorized modification. It does not provide confidentiality.
This distinction is critical:
| Property | Signed JWT |
|---|---|
| Integrity | Yes |
| Authenticity of signer | Yes, assuming correct key validation |
| Confidentiality | No |
| Tamper resistance | Yes |
| Secret storage | No |
If confidentiality is required, use an appropriate encryption mechanism rather than assuming JWT signing provides it.
alg: noneThe none algorithm represents an unsecured JWT.
An unsecured JWT does not contain a cryptographic signature.
The problem occurs when an application's JWT validation logic accepts an attacker-controlled algorithm choice without enforcing the algorithms the application actually intends to support.
An attacker may modify the header from:
{
"alg": "RS256",
"typ": "JWT"
}to:
{
"alg": "none",
"typ": "JWT"
}If the application then accepts a token without performing the required cryptographic verification, an attacker could potentially modify claims without possessing the legitimate signing key.
For example, changing:
{
"sub": "1001",
"role": "user"
}to:
{
"sub": "1",
"role": "admin"
}would become a serious privilege-escalation vulnerability.
The problem isn't simply that "none" exists.
The real problem is trusting an attacker-controlled algorithm selection or incorrectly configuring the JWT library.
Modern JWT libraries generally do not accept unsecured tokens by default, but developers should never depend on accidental library defaults for security.
Explicitly configure the algorithms your application expects.
For example, with Node.js jsonwebtoken:
jwt.verify(token, publicKey, {
algorithms: ["RS256"]
});The application should reject tokens using algorithms outside the expected allowlist.
Do not dynamically select the verification algorithm based solely on the token's alg value.
Algorithm confusion is a different and particularly important JWT implementation flaw.
It can occur when a system supports both symmetric and asymmetric algorithms but incorrectly handles keys according to the algorithm specified by the token.
For example:
With RS256:
Private key → signs
Public key → verifiesWith HS256:
Shared secret → signs
Shared secret → verifiesThe security model is completely different.
A badly designed verification implementation may accidentally allow an attacker to manipulate the token's declared algorithm and cause the server to use a public key in a manner intended for HMAC verification.
The result can be catastrophic if the implementation accepts the wrong algorithm/key combination.
Do not merely check that an algorithm exists.
Bind the expected algorithm to the expected key type and trust context.
For example:
Expected token:
RS256 + RSA public keyshould not silently become:
HS256 + RSA public keyYour application should have an explicit cryptographic policy such as:
Issuer A
↓
RS256
↓
Approved RSA public key
↓
Allowed audiencesDo not allow arbitrary combinations.
A signed JWT should never be accepted merely because it has the correct structure.
The application must cryptographically verify the signature.
A common conceptual mistake looks like this:
decode(token)
↓
read payload
↓
trust user IDThat is not authentication.
Decoding a JWT and verifying a JWT are completely different operations.
A decoder answers:
"What data is inside this token?"
A verifier answers:
"Was this data validly signed by a trusted signer under the expected cryptographic rules?"
Never confuse the two.
The validation pipeline should look more like:
Receive token
↓
Parse safely
↓
Determine whether token type is expected
↓
Apply allowed algorithm policy
↓
Select trusted verification key
↓
Verify cryptographic signature
↓
Validate registered claims
↓
Validate application-specific requirements
↓
Authorize requested operationEvery stage matters.
HS256 is a symmetric HMAC-based signing algorithm.
The same secret is used to create and verify the signature.
That means the security of the system depends heavily on the secrecy and strength of that secret.
Weak examples include:
secret
password
12345678
jwtsecret
companyname
admin123If an attacker obtains a valid HS256 token, they may be able to perform an offline guessing attack against a weak secret.
Unlike an online password attack, offline guessing does not require repeatedly interacting with the target server.
The attacker can test candidate secrets locally.
If the secret is discovered, the attacker can potentially create valid-looking tokens that pass signature verification.
Use a cryptographically random secret with sufficient entropy.
Do not derive the signing secret from:
Store signing secrets in an appropriate secret-management system rather than committing them to source control.
For larger systems, consider a dedicated key-management service or hardware-backed key infrastructure where appropriate.
A strong secret is useless if it is publicly exposed.
One common mistake is putting something like this directly into application code:
const JWT_SECRET = "super-long-secret";The secret may eventually appear in:
Use a dedicated secrets-management mechanism.
Examples include:
Environment / secret manager
↓
Application
↓
JWT signing operationDo not expose the secret to frontend code.
Do not put signing secrets inside mobile applications.
Do not assume that obfuscating a secret inside an Android or desktop application makes it secret.
If a secret is embedded in software distributed to users, assume a determined attacker can eventually extract it.
A JWT may contain:
{
"sub": "12345",
"role": "admin"
}The presence of "role": "admin" does not mean your application should automatically authorize every administrative operation.
Authentication and authorization are different decisions.
Authentication asks:
Who is this principal?
Authorization asks:
Is this principal allowed to perform this particular operation on this particular resource?
Suppose an API receives:
GET /users/500/profileThe fact that the JWT is valid does not automatically mean the authenticated user can access user 500.
Your application still needs an authorization decision.
Perform authorization based on the application's security model.
For example:
Valid JWT
↓
Authenticated principal = user 123
↓
Requested resource = user 500
↓
Authorization policy
↓
DENYJWT validation should never replace proper authorization logic.
expThe exp claim defines the expiration time of a token.
If an application issues tokens without meaningful expiration controls, a stolen token may remain usable for an unnecessarily long period.
Imagine an access token being stolen from a compromised endpoint.
If that token remains valid indefinitely, the attacker may continue using it until another control invalidates it.
That creates a much larger attack window.
Use expiration for access tokens.
For example:
{
"sub": "123",
"iat": 1765550000,
"exp": 1765550900
}The exact lifetime should depend on the application.
There is no universal "correct" lifetime.
High-risk applications may require shorter lifetimes, while other environments may tolerate longer ones.
The important point is that expiration should be deliberate rather than accidental.
exp, nbf, and Clock ValidationIncluding time-based claims isn't enough.
The application must actually validate them.
Important registered claims include:
expExpiration time.
The token should not be accepted after the expiration time, subject to carefully controlled clock-skew handling.
nbfNot-before time.
A token should generally not be accepted before the specified time.
iatIssued-at time.
This identifies when the token was issued, although its presence alone does not make a token valid.
Developers should also avoid allowing excessive clock skew.
A huge tolerance can effectively extend token lifetimes.
Use a trusted JWT library and configure reasonable clock tolerance.
Do not implement time validation with ad-hoc string comparisons or custom logic unless there is a strong reason to do so.
The iss claim identifies the issuer of a token.
Consider an environment with multiple identity providers or multiple applications.
A token might have a valid signature but originate from an issuer your API does not trust.
If the API verifies only the signature and ignores the issuer, it may accept a token outside its intended trust relationship.
If your architecture depends on an issuer, explicitly validate it.
For example:
{
"iss": "https://identity.example.com"
}The API should compare the claim against its configured trusted issuer.
Do not treat any validly signed token from any issuer as automatically acceptable.
The aud claim identifies the intended audience of a token.
This is especially important when one identity provider issues tokens for multiple services.
Imagine:
Identity Provider
│
├── API A
├── API B
└── API CA token intended for API A should not automatically be accepted by API C merely because it was signed by the same trusted identity provider.
{
"iss": "https://identity.example.com",
"aud": "payments-api"
}The payments API should verify that the token is actually intended for it.
Validate the expected audience according to your architecture.
This is one of the easiest JWT checks to overlook and one of the most important in multi-service environments.
kid HandlingThe kid claim can identify which signing key was used.
For example:
{
"alg": "RS256",
"kid": "key-2026-01"
}This becomes important when a system rotates signing keys.
However, kid is also attacker-controlled input.
A dangerous implementation may treat it as an arbitrary filename, database identifier, URL, or filesystem path without proper validation.
The problem isn't kid itself.
The problem is using attacker-controlled key identifiers in unsafe ways.
Maintain a trusted mapping:
kid
↓
trusted keyDo not allow arbitrary user-controlled paths or URLs to determine where verification keys are loaded from.
If using JWKS, fetch keys only from trusted, configured locations and apply appropriate caching and validation.
A common mistake in larger systems is treating different token types as interchangeable.
For example:
Access token
ID token
Refresh token
Email verification token
Password reset token
API tokenThese tokens may all use JWT-like structures, but their intended purposes are different.
An ID token is intended to communicate authentication information to a client application in an OpenID Connect flow.
An access token is intended for access to protected resources.
A refresh token has a different lifecycle and purpose.
Accepting one token type where another is expected can create serious security problems.
Define token purpose explicitly.
Validate not only the cryptographic signature but also the token's intended use and relevant claims.
Do not build a generic:
verifyAnyJWT()function and use it everywhere without considering token type and trust context.
A JWT payload may be easy to decode.
Therefore, avoid storing:
{
"password": "...",
"credit_card": "...",
"private_key": "...",
"api_secret": "..."
}Even if the JWT is signed correctly, those values remain readable to anyone who obtains the token.
A better payload contains only the information actually needed.
For example:
{
"sub": "123456",
"iss": "https://identity.example.com",
"aud": "https://api.example.com",
"exp": 1765550900
}Application-specific claims can be included where justified, but unnecessary data increases exposure and makes token management harder.
A signed JWT protects integrity.
It does not automatically provide confidentiality.
Even non-secret information can become a privacy problem if the token contains too much.
Developers sometimes put entire user profiles inside JWTs:
{
"name": "...",
"email": "...",
"phone": "...",
"address": "...",
"department": "...",
"permissions": [...],
"preferences": [...],
"internal_flags": [...]
}That creates unnecessary exposure.
JWTs are often copied into:
The more information inside the token, the more information can leak through those secondary systems.
Put only what the verifier actually needs.
localStorageBrowser applications sometimes store access tokens like:
localStorage.setItem("jwt", token);This is convenient but creates a major concern.
JavaScript running in the application's origin can generally access localStorage.
Therefore, if an attacker achieves XSS, malicious JavaScript may be able to read the token.
For example, conceptually:
const token = localStorage.getItem("jwt");If that credential is a bearer access token, possession may be enough to authenticate as the victim.
The first priority is still:
Prevent XSS.
Use:
For browser authentication, an HttpOnly cookie can also prevent JavaScript from directly reading the authentication cookie.
But that leads to another security consideration: CSRF.
HttpOnlyAn HttpOnly cookie cannot be read directly through normal JavaScript APIs such as document.cookie.
That is useful against token extraction through XSS.
But HttpOnly does not stop XSS.
Suppose malicious JavaScript executes inside the victim's origin.
It may not be able to read the cookie, but the browser may still automatically attach that cookie to requests.
The attacker may therefore be able to perform actions as the victim even without seeing the token.
This distinction is critical.
HttpOnly helps protect:
Token confidentiality from JavaScriptIt does not guarantee:
Application integrity against XSSXSS remains a serious vulnerability and should be fixed directly.
When JWTs are stored in cookies, the browser can automatically send them with matching requests.
That introduces a CSRF consideration.
An attacker may attempt to cause the victim's browser to send an authenticated request to the target application.
Therefore, switching from localStorage to cookies is not a complete security solution.
Depending on the application, configure cookies with appropriate attributes such as:
HttpOnly
Secure
SameSiteFor example:
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=LaxThe correct SameSite setting depends on the application's architecture.
For state-changing operations, additional CSRF defenses may also be required.
Possible approaches include:
Do not blindly copy one cookie configuration into every application.
A token valid for several days may be convenient, but convenience increases the impact of theft.
Consider:
15-minute access tokenversus:
30-day access tokenIf both are stolen, the second credential potentially provides a much larger window for abuse.
Short-lived access tokens reduce the lifetime of a stolen credential.
But extremely short lifetimes can also create unnecessary complexity and refresh traffic.
The correct lifetime is therefore a risk decision.
A common approach is:
Short-lived access token
+
Longer-lived refresh token
+
Refresh-token rotation
+
Reuse detectionThis provides a better balance between usability and security than making the access token itself extremely long-lived.
Refresh tokens deserve special attention.
A refresh token is effectively a credential that can be exchanged for new access tokens.
If an attacker steals one, the attacker may be able to continue obtaining access tokens even after an individual access token expires.
A stronger architecture uses rotation.
Conceptually:
Refresh Token A
↓
Exchange
↓
Access Token B
Refresh Token BThe previous refresh token becomes invalid.
If the server later sees Refresh Token A being reused, that can indicate theft or replay.
The server can then invalidate the associated refresh-token family/session and require reauthentication.
Refresh-token rotation is not mandatory for every architecture, but it is an important security pattern when long-lived refresh credentials are used.
JWTs are often described as "stateless."
That is useful, but it creates a trade-off.
If a server issues a self-contained access token that remains valid until expiration, simply deleting something from a database does not automatically invalidate the token.
The token may continue to pass cryptographic verification.
This becomes a problem during:
Depending on your architecture, you can use:
There is no universal revocation mechanism.
The important thing is to understand the trade-off between stateless validation and immediate invalidation.
This is a common misconception.
Suppose a user clicks:
LogoutThe browser removes the token.
That does not necessarily invalidate a copy of the token that an attacker already stole.
If the API has no server-side revocation mechanism and the token has not expired, the attacker may still be able to use it.
For browser applications, invalidate the client-side session and handle refresh-token/session revocation server-side where appropriate.
Use short-lived access tokens to limit the usefulness of stolen access credentials.
For high-risk applications, implement stronger server-side session controls.
Signing keys should not remain unchanged forever.
Eventually you may need to:
A production system should be designed for key rotation before it becomes an emergency.
A typical asymmetric setup may look like:
Old private key ── signs/previously signed tokens
Old public key ── verifies existing tokens
New private key ── signs new tokens
New public key ── verifies new tokensDuring a controlled rotation, the service may temporarily publish both old and new verification keys.
The kid claim can identify which key was used.
Key rotation should be planned, tested, monitored, and reversible where possible.
Do not wait until a key is compromised to discover that your architecture has no practical rotation mechanism.
A dangerous mindset is:
"If the JWT says the user is an administrator, the user is an administrator."
The JWT is only one input into the authorization system.
The application should define its own security policy.
For example:
JWT:
sub = 123
role = admin
Request:
DELETE /users/456The application should still determine:
A token claim should not become a shortcut around authorization.
Different services may have different trust requirements.
For example:
Web application
Mobile API
Admin API
Payment API
Internal serviceUsing one generic JWT validation function everywhere can create dangerous assumptions.
An admin service may require:
issuer = X
audience = admin-api
algorithm = RS256
scope = adminwhile another service may require:
issuer = X
audience = mobile-api
algorithm = RS256
scope = userDefine explicit validation policies per trust boundary.
The question should not simply be:
"Is this JWT valid?"
It should be:
"Is this particular token valid and acceptable for this particular security context?"
That is a much stronger security model.
A robust JWT validation process should consider several independent properties.
A simplified model is:
1. Receive token
↓
2. Parse safely
↓
3. Apply token-type policy
↓
4. Enforce expected algorithm
↓
5. Select trusted key
↓
6. Verify signature
↓
7. Validate issuer
↓
8. Validate audience
↓
9. Validate expiration
↓
10. Validate not-before
↓
11. Validate required claims
↓
12. Apply authorization policy
↓
13. Allow or deny requestNot every application requires every claim, but the validation policy should be deliberate.
Suppose an API expects access tokens issued by:
https://identity.example.comand intended for:
https://api.example.comA reasonable policy could be:
Algorithm:
RS256
Issuer:
https://identity.example.com
Audience:
https://api.example.com
Required:
sub
iss
aud
exp
Optional:
iat
nbf
scopeThe exact values depend on the application.
The important point is that they are configured by the server, not discovered dynamically from attacker-controlled token content.
Both algorithms can be secure when correctly implemented.
HS256 uses one shared secret.
Shared Secret
/ \
Sign VerifyAdvantages:
Disadvantages:
RS256 uses asymmetric cryptography.
Private key → Sign
Public key → VerifyAdvantages:
Disadvantages:
Do not say:
"RS256 is always more secure."
Instead:
Choose an algorithm based on your architecture, key-management model, ecosystem support, and security requirements.
This question is often ignored.
JWTs are not automatically better than server-side sessions.
For a traditional web application, a server-side session may be simpler:
Browser
↓
Session cookie
↓
Server-side session
↓
User stateThe server can immediately invalidate the session.
JWT-based systems often move more state into the token:
Client
↓
JWT
↓
API validates tokenThis can be useful for distributed systems, but it also creates additional responsibilities around:
Don't ask:
"How can I use JWT?"
Ask:
"Do I actually need JWT for this security architecture?"
Sometimes the answer is yes.
Sometimes a conventional server-side session is the better engineering choice.
Another common misunderstanding is treating JWT and OAuth as interchangeable.
They are not.
JWT is a token format.
OAuth 2.0 is an authorization framework.
OpenID Connect builds an identity layer on top of OAuth 2.0.
A system can use OAuth 2.0 with JWT access tokens, but OAuth does not require every token to be a JWT.
Likewise, using JWT does not automatically mean an application is implementing OAuth correctly.
This distinction matters when designing authentication and authorization systems.
OpenID Connect introduces the concept of an ID token.
An ID token is intended to communicate authentication information to the client application.
An access token is intended for access to a protected resource.
They serve different purposes.
A common architectural mistake is taking an ID token and sending it to an API as if it were an access token.
That can create token-confusion vulnerabilities.
Validate tokens according to their intended purpose.
Do not assume:
"JWT" = "usable everywhere"There is no universal storage mechanism that eliminates every browser security risk.
Two commonly discussed approaches are:
Examples:
localStorage
sessionStorage
in-memory application stateThe major concern is token exposure if malicious JavaScript executes within the application origin.
The browser can send the cookie automatically while JavaScript cannot directly read an HttpOnly cookie.
This can reduce token extraction through XSS.
However, cookie-based authentication introduces CSRF considerations.
Don't ask only:
"Where is the token stored?"
Also ask:
Security is about the complete system, not one storage API.
Mobile applications have a different threat model.
Do not embed a JWT signing secret inside the application and assume it is protected.
A mobile application distributed to users should be treated as potentially inspectable.
For authentication credentials, use the platform's secure credential-storage mechanisms where appropriate.
Also consider:
A JWT design that is acceptable for a browser application may not be appropriate for a mobile application without modification.
Developers sometimes log complete authorization headers during debugging:
Authorization: Bearer eyJ...This is dangerous.
A bearer token should generally be treated as a credential.
If it appears in:
anyone who gains access to those systems may potentially obtain usable credentials.
Redact credentials.
Instead of:
Authorization: Bearer eyJhbGciOi...log something like:
Authorization: [REDACTED]If token correlation is needed, use carefully designed identifiers rather than storing the complete credential.
Avoid putting bearer tokens in URLs whenever possible.
For example:
https://example.com/download?token=eyJ...URLs can leak through:
Use appropriate authorization headers or secure cookie-based mechanisms instead, depending on the architecture.
Error handling should never accidentally expose credentials.
Avoid responses such as:
{
"error": "Invalid token",
"token": "eyJ..."
}or stack traces that include:
Authorization: Bearer ...Production error messages should contain enough information for the client to understand the failure without exposing authentication material.
JWT security does not replace HTTPS.
A correctly signed JWT can still be stolen if the transport or endpoint is compromised.
Always protect authentication traffic using TLS.
For browser cookies, the Secure attribute should be used so the browser sends the cookie only over secure connections.
Also consider the security of:
"Internal network" does not automatically mean "trusted network."
This is one of the most important questions in JWT security.
If an attacker obtains an HS256 signing secret, they may be able to forge tokens.
If an attacker obtains an asymmetric private signing key, they may also be able to create valid tokens.
That means signing-key compromise can be much more serious than the theft of a single access token.
A mature architecture should therefore have a key-compromise plan.
That plan may include:
Detect compromise
↓
Stop signing with compromised key
↓
Publish replacement verification key
↓
Rotate signing key
↓
Invalidate/reject affected credentials
↓
Investigate exposure
↓
Reauthenticate affected users where necessaryThe exact procedure depends on the architecture.
From a penetration-testing perspective, JWT testing should focus on the application's actual trust assumptions.
A security assessment should examine questions such as:
exp validated?nbf validated where required?iss validated?aud validated?kid handled safely?The goal isn't simply to find a malformed JWT.
The goal is to determine whether an attacker can turn a JWT implementation weakness into authentication bypass, account takeover, privilege escalation, unauthorized access, or persistent session abuse.
A reasonably mature architecture may look like this:
Identity Provider
│
│
Authenticate
│
▼
Issue access token
│
▼
┌────────────────────┐
│ Short-lived JWT │
│ │
│ iss │
│ aud │
│ sub │
│ exp │
│ iat │
│ scope │
└────────────────────┘
│
▼
API Gateway
│
Validate JWT policy
│
┌────────────────┴────────────────┐
▼ ▼
Authentication Authorization
│ │
│ │
└──────────────┬──────────────────┘
▼
API operationRefresh tokens can be handled separately through a dedicated token lifecycle mechanism.
Before accepting a JWT, your application should have an explicit answer to these questions:
exp required and validated where appropriate?nbf validated where applicable?iss validated?aud validated?iat handled appropriately?kid handled safely?A production API might define a policy similar to:
Token type:
Access token
Algorithm:
RS256
Issuer:
https://identity.example.com
Audience:
https://api.example.com
Required claims:
sub
iss
aud
exp
Optional claims:
iat
nbf
scope
Access-token lifetime:
Short-lived
Refresh tokens:
Rotated
Refresh-token reuse:
Detected and revoked
Transport:
TLS
Cookie storage:
HttpOnly + Secure + appropriate SameSite policy
CSRF:
Protected where cookie-based authentication requires it
Signing keys:
Managed centrally and rotated periodicallyThis is an example architecture, not a universal configuration.
Your actual values should be determined by your application's threat model and identity architecture.
These are some of the JWT mistakes worth looking for during code reviews:
Decode JWT → trust payloadProblem: decoding is not verification.
Accept whatever `alg` the token specifiesProblem: attacker-controlled algorithm selection.
Use "secret123" as HS256 secretProblem: weak signing key.
JWT_SECRET = "..."inside source code.
Problem: secret exposure through source control and build artifacts.
Check signature onlyProblem: a correctly signed token can still have the wrong issuer, audience, token type, or authorization context.
Use ID token as API access tokenProblem: token-purpose confusion.
Put JWT into URLProblem: credential leakage through logs, history, analytics, and referrers.
Log Authorization headerProblem: credentials can end up in centralized logs.
Store long-lived bearer token in localStorageProblem: increased impact if XSS exposes the token.
HttpOnly cookie = XSS solvedProblem: HttpOnly prevents JavaScript from reading the cookie; it does not eliminate XSS or necessarily prevent authenticated actions initiated by malicious JavaScript.
JWT security can be summarized in one sentence:
Never trust anything inside a JWT until the token has been validated according to an explicit security policy.
That includes:
alg
kid
sub
iss
aud
exp
nbf
role
scope
permissionsSome values may become trustworthy after cryptographic verification, but that still doesn't mean they automatically authorize every action.
A secure system separates:
Token authenticity
↓
Claim validation
↓
Authentication
↓
Authorization
↓
Application security policyEach layer has a different job.
Before deploying a JWT-based authentication system, verify all of the following:
alg value to determine security policy.kid safely.exp where expiration is required.nbf where applicable.iss when issuer identity matters.aud when audience restriction matters.HttpOnly, Secure, and appropriate SameSite settings.JWTs are neither inherently insecure nor inherently secure.
They are a tool.
Used correctly, signed JWTs can work well in distributed API architectures, especially when combined with well-designed identity systems, strong key management, explicit validation policies, short-lived access credentials, and carefully implemented refresh-token handling.
Used incorrectly, JWTs can turn a small implementation mistake into a complete authentication bypass.
The biggest lesson is that signature verification is only one part of JWT security.
A secure API should determine:
Who issued this token?
↓
Was it signed correctly?
↓
Was the expected algorithm used?
↓
Is the signing key trusted?
↓
Is the token intended for this API?
↓
Is it still valid?
↓
Is it the correct type of token?
↓
What principal does it represent?
↓
Is that principal authorized for this operation?That is the mindset developers should adopt.
Don't build authentication around the assumption that:
"The JWT is valid, therefore everything inside it can be trusted."
Instead, build a clearly defined trust boundary around every token.
Verify the cryptography. Validate the claims. Confirm the token's purpose. Enforce authorization. Protect the token throughout its lifecycle.
That is what turns JWT from a convenient token format into a properly engineered authentication component.
For security professionals and developers, the goal should not be to simply make a JWT difficult to forge. The goal is to make the entire authentication and authorization system resilient when tokens are malformed, stolen, replayed, misissued, expired, or deliberately manipulated.