DEV Community

Cover image for JWT Vulnerabilities: The Auth Bugs Hiding Behind a Green Checkmark

JWT Vulnerabilities: The Auth Bugs Hiding Behind a Green Checkmark

How algorithm confusion, the none algorithm, weak signing secrets, missing expiration checks, and improper claim validation can turn a cryptographically signed token into an attacker's path to unauthorized access.

Why JWTs get trusted more than they should

A JWT looks like proof.

Three base64url-encoded segments, a signature at the end, and a library that returns true or false can create the impression that a token is either valid or invalid. That confidence is exactly where many JWT security problems begin.

A signature only establishes integrity if the verifier agrees with the application about which algorithm was used, which key should verify it, and whether the token is still valid in context.

Get any of those wrong — or allow attacker-controlled input to influence the verification process — and "the signature checks out" can stop meaning what developers think it means.

JWTs are used for API authentication, SSO, mobile sessions, password-reset flows, and service-to-service authentication. That makes JWT verification logic part of a security boundary, and small implementation mistakes can have serious consequences.

The anatomy, quickly

A JWT consists of three base64url-encoded segments joined by dots:

header.payload.signature

For example:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwiYWRtaW4iOnRydWV9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

The first two segments are encoded, not encrypted. Anyone holding the token can decode them without knowing the signing key:

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

// payload
{
  "sub": "1234",
  "admin": true
}
Enter fullscreen mode Exit fullscreen mode

That header is important because the token carries its own algorithm identifier.

The server therefore has to decide whether that algorithm is acceptable. It should not blindly accept whatever the token claims.

The payload is also attacker-controlled until the signature has been successfully verified. A decoded claim is data — not authentication.

Bug #1: Accepting the none algorithm

JWT defines none as an algorithm meaning that the token has no digital signature.

That can be legitimate in narrowly controlled scenarios, but an application using signed JWTs for authentication should not allow an attacker to switch a token into an unsigned mode.

The vulnerable pattern looks conceptually like this:

// DON'T DO THIS — trusting the algorithm supplied by the token
$header = json_decode(base64_decode($parts[0]), true);

if ($header['alg'] === 'none') {
    // Never treat an attacker-controlled header as permission
    // to skip authentication.
    return json_decode(base64_decode($parts[1]), true);
}
Enter fullscreen mode Exit fullscreen mode

An attacker could modify the payload:

{
  "sub": "1234",
  "admin": true
}
Enter fullscreen mode Exit fullscreen mode

and submit an unsigned token such as:

<modified-header>.<modified-payload>.
Enter fullscreen mode Exit fullscreen mode

The important security flaw isn't the existence of none. It's allowing untrusted token input to determine whether signature verification happens at all.

Fix

The application should determine the accepted algorithm server-side.

With firebase/php-jwt, for example:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$decoded = JWT::decode(
    $token,
    new Key($secret, 'HS256')
);
Enter fullscreen mode Exit fullscreen mode

The verifier is explicitly configured to use HS256. The token does not get to select a different verification algorithm.

Modern JWT libraries have significantly reduced this class of vulnerability by making explicit algorithm/key configuration part of their APIs. The remaining risk is usually application code that bypasses those protections or reintroduces dynamic algorithm selection.

Bug #2: Algorithm confusion — RS256 tokens verified as HS256

Algorithm confusion is more subtle.

It primarily affects implementations using asymmetric algorithms such as RS256, where:

  • the issuer signs with a private key;
  • the verifier checks the signature with the corresponding public key.

The public key is intentionally not secret. It may even be published through a JWKS endpoint.

The dangerous implementation pattern is one where the token can influence the selected algorithm and the verification code is willing to use the same configured key material across incompatible algorithm families.

Conceptually:

// DON'T DO THIS
$header = json_decode(base64_decode($parts[0]), true);

$key = getPublicKeyForVerification();

// The token is influencing which verification algorithm is used.
$decoded = JWT::decode(
    $token,
    new Key($key, $header['alg'])
);
Enter fullscreen mode Exit fullscreen mode

If an implementation changes from RSA verification to HMAC verification based on an attacker-controlled alg value, the public RSA key may be interpreted as an HMAC secret.

Because the public key is not secret, an attacker who knows it could potentially construct an HMAC-signed token using that public value.

Conceptually:

$forgedToken = JWT::encode(
    ['sub' => '1', 'admin' => true],
    $publicKeyPem,
    'HS256'
);
Enter fullscreen mode Exit fullscreen mode

Whether this works depends on the exact library and application configuration. It is not a universal property of JWT or RS256.

Fix

Pin the algorithm and match it to the expected key type:

$decoded = JWT::decode(
    $token,
    new Key($rsaPublicKey, 'RS256')
);
Enter fullscreen mode Exit fullscreen mode

If multiple algorithms are genuinely required, the application should maintain an explicit mapping between algorithms and compatible keys rather than allowing the token to select an arbitrary combination.

For example:

RS256 → RSA public key
ES256 → EC public key
HS256 → HMAC secret
Enter fullscreen mode Exit fullscreen mode

The token's alg value should be treated as an input to validate, not as an instruction the server must obey.

Bug #3: Weak, guessable, or brute-forceable HMAC secrets

HS256 security depends heavily on the secrecy and unpredictability of the HMAC key.

Weak defaults are still a common problem:

// DON'T DO THIS
$secret = 'secret';
$secret = env('JWT_SECRET', 'changeme');
$secret = 'my-app-jwt-key-2023';
Enter fullscreen mode Exit fullscreen mode

An attacker who obtains a valid HS256 token can test candidate secrets offline. There is no login endpoint or rate limiter involved in that process.

If the secret is weak enough to appear in a dictionary or predictable pattern, an attacker may recover it and then generate valid tokens.

Fix

Generate a cryptographically random secret:

$secret = bin2hex(random_bytes(32));
Enter fullscreen mode Exit fullscreen mode

That produces 256 bits of random data.

Store the resulting value securely rather than committing it to source control:

JWT_SECRET=<random-secret>
Enter fullscreen mode Exit fullscreen mode

Do not ship weak development defaults such as:

secret
changeme
password
jwt-secret
my-app-key
Enter fullscreen mode Exit fullscreen mode

For architectures where multiple independent services need to verify tokens but should not be able to mint them, asymmetric signing can also reduce the blast radius of a compromised verification component because the verifier only needs the public key.

Bug #4: Missing or ignored expiration

The exp claim defines when a JWT expires.

For example:

{
  "sub": "1234",
  "iat": 1750000000,
  "exp": 1750000900
}
Enter fullscreen mode Exit fullscreen mode

The important distinction is that the payload itself is not trusted simply because it contains exp.

The application must verify the token's signature and then enforce the registered claims according to its security policy.

A vulnerable implementation might simply decode the payload:

// DON'T DO THIS
$payload = json_decode(
    base64_decode($parts[1]),
    true
);

$userId = $payload['sub'];
Enter fullscreen mode Exit fullscreen mode

That is not authentication.

A secure verifier should perform cryptographic verification and enforce expiration.

For example:

$payload = [
    'sub' => $userId,
    'iat' => time(),
    'exp' => time() + 900, // 15 minutes
];

$token = JWT::encode(
    $payload,
    $secret,
    'HS256'
);
Enter fullscreen mode Exit fullscreen mode

With firebase/php-jwt, expiration is checked during decoding when the exp claim is present and validation has not been disabled.

try {
    $decoded = JWT::decode(
        $token,
        new Key($secret, 'HS256')
    );
} catch (\Firebase\JWT\ExpiredException $e) {
    respond_unauthorized();
}
Enter fullscreen mode Exit fullscreen mode

But there is an important application-level question:

What happens if exp is missing?

If your security model requires every access token to expire, a missing exp should be rejected rather than treated as "no expiration."

Short-lived access tokens combined with a separate refresh-token mechanism are generally easier to contain than long-lived access tokens.

Bug #5: Missing issuer and audience validation

A token can have a perfectly valid signature and still be the wrong token for the endpoint receiving it.

This matters especially in microservice architectures.

Imagine:

Authentication Service
        │
        ├── issues token for billing-service
        │
        ▼
Billing API
Enter fullscreen mode Exit fullscreen mode

and another service:

Authentication Service
        │
        └── issues token for admin-service
Enter fullscreen mode Exit fullscreen mode

If multiple services trust the same signing infrastructure but don't validate the token's intended audience, a valid token issued for one service may potentially be replayed against another.

A verifier that only checks the signature:

$decoded = JWT::decode(
    $token,
    new Key($secret, 'HS256')
);

$userId = $decoded->sub;
Enter fullscreen mode Exit fullscreen mode

hasn't established that the token was actually intended for this service.

Fix

Include issuer and audience claims where your architecture requires them:

$payload = [
    'iss' => 'https://auth.example.com',
    'aud' => 'billing-service',
    'sub' => $userId,
    'iat' => time(),
    'exp' => time() + 900,
];
Enter fullscreen mode Exit fullscreen mode

Then validate them during verification:

if (
    $decoded->iss !== 'https://auth.example.com' ||
    $decoded->aud !== 'billing-service'
) {
    respond_unauthorized();
}
Enter fullscreen mode Exit fullscreen mode

The exact validation mechanism depends on the JWT library.

The important principle is:

A valid signature does not automatically mean a valid token for this service.

Bug #6: Unsafe kid handling

The kid header identifies a signing key.

For example:

{
  "alg": "RS256",
  "kid": "key-2026-01",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Using kid to select among trusted keys is legitimate and common.

The problem appears when an application treats the attacker-controlled value as a trusted filesystem path, database expression, URL, or query fragment.

For example:

// DON'T DO THIS
$key = file_get_contents(
    "/keys/{$header['kid']}.pem"
);
Enter fullscreen mode Exit fullscreen mode

Or:

// DON'T build raw queries from attacker-controlled input.
$key = DB::select(
    "SELECT key FROM keys WHERE id = '{$header['kid']}'"
);
Enter fullscreen mode Exit fullscreen mode

Depending on the surrounding implementation, unsafe handling could create path traversal, injection, unexpected key selection, or other vulnerabilities.

Fix

Treat kid as untrusted input.

Prefer a strict lookup against known identifiers:

$allowedKeys = [
    'key-2026-01' => $rsaPublicKey,
    'key-2026-02' => $nextRsaPublicKey,
];

if (!isset($allowedKeys[$kid])) {
    respond_unauthorized();
}

$key = $allowedKeys[$kid];
Enter fullscreen mode Exit fullscreen mode

If a database is required, use parameterized queries and constrain the lookup to the keys that the service is actually authorized to trust.

Never allow an arbitrary kid value to become an arbitrary filesystem path or query.

What your JWT library already does for you

Modern JWT libraries handle a significant portion of the cryptographic work, but they cannot determine your application's trust model.

For example, firebase/php-jwt requires the verification key and allowed algorithm to be supplied to decode(). That helps prevent applications from blindly accepting an algorithm selected by the token.

lcobucci/jwt similarly uses explicit configuration for signing and validation rather than treating the token's header as an authorization decision.

The important distinction is:

The library can enforce cryptographic rules. It cannot decide your application's security policy.

It cannot know:

  • which issuer your application trusts;
  • which audience this API should accept;
  • whether every access token must contain exp;
  • how long tokens should live;
  • which users or sessions should be revoked;
  • which services should trust which tokens;
  • whether a kid is authorized for a particular endpoint.

Those are application-level decisions.

What libraries cannot fix

A correctly configured library cannot compensate for a weak secret:

$secret = 'secret';
Enter fullscreen mode Exit fullscreen mode

It cannot add an aud claim that you never issued.

It cannot decide that an old token should be revoked because a user's permissions changed.

And it cannot protect an application if developers decode the payload manually and use its claims before verification.

The rule is simple:

Decode for inspection if you need to. Trust only after verification.

Detection as a second layer

A correctly configured JWT implementation should prevent the known verification failures.

Detection still matters because authentication systems evolve.

Useful signals include:

  • unexpected algorithm values;
  • repeated algorithm mismatches;
  • malformed JWT structures;
  • unusual or invalid kid values;
  • repeated signature-verification failures from the same source;
  • tokens with audiences that don't match the endpoint;
  • repeated requests using expired tokens;
  • sudden changes in authentication failure patterns.

But context matters.

A single failed JWT can simply be a broken client.

A malformed kid can be a scanner, a misconfigured integration, or a developer testing an endpoint.

A burst of different algorithm values against the same authentication endpoint is more interesting because it may indicate systematic probing.

The important word is pattern.

A detection signal is not proof of an attack, and detection is not the same as prevention.

This is where an application-level security layer such as Kriosa can complement secure JWT verification.

See It Blocked: Try the Live Sandbox

Reading about an alg: none bypass is one thing — watching it get stopped is another. The Kriosa sandbox lets you send a forged token (unsigned, or algorithm-confused) at a Kriosa-protected auth endpoint and watch it get flagged and blocked in real time — no signup required.

Try the JWT sandbox →

What Is Kriosa?

Kriosa is an application-level security layer for PHP and Laravel applications.

It sits at the application boundary and analyzes incoming requests for suspicious traffic before that traffic reaches sensitive application logic.

For JWT-based authentication, Kriosa can provide an additional detection and visibility layer around suspicious token activity, such as:

  • unexpected algorithm values;
  • repeated malformed tokens;
  • suspicious kid patterns;
  • authentication probing;
  • repeated verification failures;
  • unusual token-related request behavior.

But Kriosa is not a replacement for secure JWT verification.

The primary defense remains the verification implementation itself.

Your application still needs to:

  1. Pin accepted algorithms.
  2. Use strong signing keys.
  3. Verify signatures correctly.
  4. Enforce expiration.
  5. Validate issuer and audience where required.
  6. Safely resolve kid.
  7. Implement appropriate revocation/session controls.

Kriosa adds visibility around the traffic reaching those controls.

How Kriosa Can Help Detect Suspicious JWT Activity

JWT abuse is fundamentally an application-layer problem.

The dangerous condition isn't simply:

JWT verification failed
Enter fullscreen mode Exit fullscreen mode

because legitimate applications generate plenty of those.

A mobile client may send an expired token.

A browser may hold stale authentication state.

A service integration may send a malformed token after a deployment.

The more useful signal is behavior across requests.

For example:

Request 1 → alg: HS256
Request 2 → alg: RS256
Request 3 → alg: none
Request 4 → malformed kid
Request 5 → repeated signature failure
Request 6 → different malformed kid
Enter fullscreen mode Exit fullscreen mode

One request may be meaningless.

A sequence like this provides much more useful security telemetry.

Kriosa can help developers identify, log, investigate, and respond to suspicious application-layer authentication behavior.

The distinction remains important:

Detection is not prevention.

If JWT verification is broken, the verification code needs to be fixed.

Prevention Comes First

A secure JWT implementation should:

  • Pin accepted verification algorithms server-side.
  • Explicitly reject algorithms your service does not support.
  • Never derive the verification algorithm from the token alone.
  • Use cryptographically random HMAC secrets when HMAC is appropriate.
  • Keep signing secrets outside source control.
  • Use asymmetric signing when its trust model fits the architecture.
  • Set exp on access tokens and enforce it.
  • Validate iss and aud where tokens cross trust boundaries.
  • Validate kid against trusted key identifiers.
  • Use short-lived access tokens where appropriate.
  • Use refresh-token mechanisms for longer sessions.
  • Provide a revocation strategy appropriate to the application.
  • Log and monitor meaningful authentication failures.

These controls address the vulnerability itself.

Detection Adds Another Layer

Security controls can fail during future development.

A developer can introduce dynamic algorithm selection.

A new microservice can be added without updating audience validation.

A key rotation can introduce unsafe kid handling.

A configuration change can accidentally restore a weak development secret.

A library upgrade can change behavior that developers previously relied upon.

That's why defense in depth matters.

A practical security model can look like:

Secure verification → Claim validation → Detection → Logging & response

Kriosa fits into the detection layer.

Your verification logic should prevent the vulnerability.

Your claim validation should reject expired, misscoped, or otherwise unacceptable tokens.

Kriosa can provide additional visibility into suspicious application traffic.

Your logging and monitoring infrastructure can help your team investigate and respond.

Kriosa does not make broken JWT verification safe. It adds another layer of visibility around the traffic attempting to reach it.

JWT Security Checklist

Before shipping or auditing a JWT-based authentication flow:

  • [ ] Is the accepted algorithm pinned server-side?
  • [ ] Is the token's alg value treated as untrusted input?
  • [ ] Are unsupported algorithms explicitly rejected?
  • [ ] Is none prevented from becoming an unintended authentication bypass?
  • [ ] If using asymmetric signing, can an RSA/EC public key ever be interpreted as an HMAC secret?
  • [ ] Is the HMAC secret cryptographically random and sufficiently strong?
  • [ ] Is the secret stored outside source control?
  • [ ] Is exp present on every token that is supposed to expire?
  • [ ] Is exp actually enforced by the verification flow?
  • [ ] Are missing expiration claims rejected when expiration is mandatory?
  • [ ] Are iss and aud validated where the architecture requires them?
  • [ ] Is kid validated against a strict allowlist of known key identifiers before being used to look up a key?
  • [ ] Are access tokens short-lived, with a separate refresh-token mechanism for longer sessions?
  • [ ] Is there a revocation strategy for tokens that need to be invalidated before their natural expiration?
  • [ ] Are verification failures — especially algorithm mismatches and malformed kid values — logged and monitored?

The goal isn't to trust a signature because a library returned true. It's to make sure that true only happens when the algorithm, the key, the expiration, and the intended audience all agree with what your application actually issued and actually expects.

Try Kriosa

If you want an additional application-level security layer for your PHP or Laravel application:

See it stop a JWT bypass attempt yourself: Try the live sandbox →

Install it with Composer:

composer require kriosa-ai/kriosa-php
Enter fullscreen mode Exit fullscreen mode

Documentation: Kriosa Documentation

Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.

Kriosa — sleep better, we're awake.

Top comments (0)