How JWTs Actually Work
Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)JWTs have a branding problem. They are introduced as a tidy way to "do auth", which makes them sound like a complete authentication system in one string. Then teams discover the edge cases one by one: tokens that are readable but not encrypted, signatures that only matter if the verifier pins the algorithm, claims that mean nothing unless the application checks them, logout flows that do not really log anyone out, and browser storage choices that turn a single XSS bug into mass token theft.
A JWT is simpler and more limited than the hype suggests. At its most basic, it is a compact container for claims plus some cryptographic protection around those claims. Usually that protection is a digital signature or MAC. Sometimes it is encryption. Often it is neither enough nor even appropriate for the problem people are using it to solve.
That does not mean JWTs are bad. They are genuinely useful when you need a portable, signed set of claims that can cross service boundaries, represent delegated API access, or carry identity assertions from one system to another. OpenID Connect, OAuth 2.0 access tokens, service-to-service identity in some meshes, and many single sign-on products use them for good reasons. The trouble starts when a claim container gets treated as a universal session strategy.
This article explains the mechanism plainly. We will separate JWT from the rest of the JOSE family, break down the three token segments, look at how HS256, RS256, ES256, and EdDSA actually work, walk through a correct verification pipeline, and then spend most of our time on the places real systems fail: algorithm confusion, kid abuse, wrong-issuer acceptance, browser storage, revocation, and key rotation. The examples use a European-flavoured auth.example.eu identity service and a billing API, but the mechanics are the same everywhere.
A JWT Is a Claims Object in the JOSE Family, Not an Auth System by Itself
The first useful clarification is vocabulary.
A lot of developers use "JWT" to mean any token that looks like aaa.bbb.ccc. Strictly speaking, the ecosystem has several related standards from the IETF JOSE set published in 2015:
- JWS in RFC 7515: JSON Web Signature
- JWE in RFC 7516: JSON Web Encryption
- JWK in RFC 7517: JSON Web Key
- JWA in RFC 7518: JSON Web Algorithms
- JWT in RFC 7519: JSON Web Token
A JWT is the claims structure and compact representation convention. Most of the tokens people handle in web auth are really signed JWTs, which means a JWT carried inside a JWS compact serialisation.
That distinction matters because it explains a lot of recurring confusion.
A signed JWT is usually a JWS compact serialisation
Three dot-separated base64url segments usually mean:
base64url(header).base64url(payload).base64url(signature)The header is JOSE metadata. The payload is the claims set. The third segment is the cryptographic protection.
A JWT is not automatically encrypted
If you can split the token into three base64url segments, decode the first two, and read JSON, that is normal. The payload is typically visible to anyone holding the token. Signing protects integrity, not secrecy.
A JWE is a different thing
If confidentiality is required, JOSE supports JWE. That introduces encrypted keys, IVs, ciphertext, authentication tags, and several more algorithm choices. It is much more complex than ordinary signed JWT usage and far less common in day-to-day application auth.
A JWK or JWKS is how keys are often published
A JSON Web Key Set is just a JSON document containing public keys and metadata such as kid, kty, alg, and usage hints. Verifiers fetch or cache it so they know which public key should verify a token signed by a given issuer.
This vocabulary already removes one common source of bugs. Teams reach for "JWT" as though it answers everything from key distribution to logout semantics. It does not. It answers one narrower question: how do I package claims into a compact token format that can be signed or encrypted according to JOSE rules?
The Three Segments Are Easy to Read and Easy to Misunderstand
Consider this token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI2LTA1In0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5ldSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJiaWxsaW5nLWFwaSIsInNjb3BlIjoidHJhbnNmZXJzOndyaXRlIiwiaWF0IjoxNzc4NDk3ODAwLCJuYmYiOjE3Nzg0OTc4MDAsImV4cCI6MTc3ODQ5ODcwMCwianRpIjoiOWIzZDhkNTYtMmQ1OS00ZTRlLTg2ZDktOGY1M2Q0YzhlN2NlIn0.a1iZZ25s81mMEGmy1-sphyqf0cH18_FLEJMpsh9XdFISplit it on dots.
Segment 1: JOSE header
Decoding the first segment yields:
{"alg":"HS256","typ":"JWT","kid":"key-2026-05"}This is not the thing you trust. It is untrusted metadata from the token itself. It tells the verifier what the issuer claims to have used, not what the verifier is obliged to accept.
Fields you commonly see here:
alg: declared algorithm, such asHS256,RS256,ES256, orEdDSAtyp: oftenJWT, sometimes omittedkid: key id hint so the verifier can choose the right key
The crucial rule is: the verifier must not let the token dictate security policy. The token can say alg: HS256. The verifier still needs its own configuration saying whether this issuer, for this audience, is actually expected to use HS256.
Segment 2: payload claims
Decoding the second segment yields JSON claims:
{
"iss": "https://auth.example.eu",
"sub": "user_42",
"aud": "billing-api",
"scope": "transfers:write",
"iat": 1778497800,
"nbf": 1778497800,
"exp": 1778498700,
"jti": "9b3d8d56-2d59-4e4e-86d9-8f53d4c8e7ce"
}Again, visible does not mean trusted. The whole point of signature verification is to establish whether these claims came from an authorised issuer and were not changed.
Segment 3: signature or MAC
The third segment is computed over:
base64url(header) + "." + base64url(payload)For HS256, that is an HMAC-SHA-256 tag computed with a shared secret. For RS256, ES256, or EdDSA, it is a public-key signature produced by the issuer's private key.
Base64url is encoding, not protection
JWT segments use base64url, which differs from ordinary base64 in two main ways:
-and_replace+and/- padding
=is usually omitted
That makes the token safe for URLs and headers. It does not make the payload hidden. Anyone with the token can decode the header and payload.
This is the first practical rule many teams need to learn: never put secrets in JWT payloads just because they are not immediately human-readable. Base64url is packaging, not confidentiality.
Signing Algorithms Define the Trust Topology
The next important distinction is between symmetric and asymmetric algorithms.
HS256 uses one shared secret
With HS256, the issuer and verifier both know the same secret key. The signature is an HMAC over the header and payload.
That gives you straightforward, efficient verification, but it also means every verifier with the secret could forge a token. If three services all know the HS256 key, all three can mint tokens that appear to come from the issuer.
HS256 is appropriate when one system issues tokens and a tightly controlled set of services verify them under the same administrative boundary.
RS256 uses an RSA private key and public key
With RS256, the issuer signs with an RSA private key and verifiers use the corresponding public key. Public verifiers can confirm authenticity without gaining the power to sign future tokens.
That is why RS256 became popular with identity providers and large ecosystems. The issuer can publish public keys through JWKS, rotate them by kid, and keep the private keys isolated.
ES256 and EdDSA do the same job with different maths
ES256 uses ECDSA over the P-256 curve. EdDSA in JWT contexts usually means Ed25519. Both are public-key signature schemes. Compared with RSA they tend to give shorter signatures and keys, but operationally the important point is the same: verifiers get public keys only.
The verifier must already know the expected key type and allowed algorithms
This is where algorithm-confusion bugs come from.
If the token says alg: HS256 and your verifier is holding an RSA public key because it expected RS256, you have to reject immediately. Do not say, "perhaps the issuer switched algorithms, let me try both". That kind of flexibility produced the classic RS256/HS256 confusion attack.
In that attack, a vulnerable verifier expected an RSA-signed token but also accepted HS256. The attacker took the issuer's public RSA key, used it as the HMAC secret, changed the header to HS256, and produced a valid MAC. If the verifier reused the same key material across both algorithm families, the forged token passed. The fix is conceptually simple: bind issuer, key type, and allowed algorithm in server configuration, not in token-controlled metadata.
This is why JWT verification is not "call verify() and move on". The cryptographic primitive works only inside a policy context.
Claims Are Policy Inputs, Not Magic Fields with Automatic Meaning
Developers often talk about claims as if their names make them self-enforcing. They do not. A claim is only useful if the verifier interprets it correctly and rejects tokens that violate the expected policy.
The standard registered claims in RFC 7519 include:
iss: issuersub: subjectaud: audienceexp: expiration timenbf: not before timeiat: issued at timejti: unique token id
These are widely used, but none of them does anything by itself.
iss says who minted the token
A verifier should pin acceptable issuers. If your billing API trusts https://auth.example.eu, it should reject a token with iss: https://login.partner.example even if the signature is valid under some other key you happen to know.
This bug appears more often than people think in shared gateway code that says "valid signature from any trusted IdP is fine". It is not fine if the token came from the wrong security domain.
aud says who the token is for
This is one of the most frequently skipped checks. A token minted for mobile-app should not automatically work against billing-admin-api. If you skip audience verification, tokens leak across service boundaries.
A common real-world failure looks like this:
- IdP issues a token for front-end app A
- back-end service B accepts any valid token from the same issuer
- app A's token now works against B even though B never intended to trust it
exp, nbf, and iat define time bounds
exp is the last moment the token should be accepted.
nbf says not before this time.
iat says when it was minted.
These sound simple, but real verification code has to decide:
- what clock source to trust
- what skew to allow, often 30 to 300 seconds
- whether
iatis mandatory or merely informative - whether extremely old but not-yet-expired tokens are acceptable
jti helps with replay and revocation only if you store it somewhere
A JWT can contain a unique id, but the verifier must actually track revoked or previously used ids for that field to matter operationally. Statelessness and replay prevention pull in opposite directions.
Private claims need namespace discipline
People add claims like role, scope, tenant_id, plan, or can_impersonate. That is fine, but the semantics need to be stable and documented. If one service treats role: admin as global and another treats it as tenant-local, confusion becomes authorisation bugs.
The important mindset is that claims are policy inputs the application must explicitly validate. They are not magic switches that libraries will interpret correctly on your behalf.
Correct Verification Is a Pipeline, Not a Single Library Call
A robust verifier usually follows a fixed sequence. The order matters because later steps assume earlier policy decisions are already locked down.
A good high-level pipeline is:
- split the token into the expected number of segments
- base64url-decode header and payload safely
- parse JSON and reject malformed structures
- enforce allowed token type and allowed algorithms for this issuer
- resolve the key by trusted configuration and
kid - verify the signature or MAC with the correct key type
- validate claims: issuer, audience, time bounds, scope, subject shape
- check revocation or session version if your architecture requires it
- only then build the authenticated principal
Here is the shape in TypeScript-style pseudocode:
async function verifyAccessToken(token: string): Promise<Principal> {
const [h, p, s] = splitExactlyThreeSegments(token)
const header = parseJson(base64urlDecode(h))
const payload = parseJson(base64urlDecode(p))
enforceIssuer(payload.iss, 'https://auth.example.eu')
enforceAllowedAlg(header.alg, ['RS256'])
enforceType(header.typ, 'JWT')
const jwk = await getTrustedKey({
issuer: payload.iss,
kid: header.kid,
expectedAlg: 'RS256',
})
verifySignature({
alg: 'RS256',
signingInput: `${h}.${p}`,
signature: s,
publicKey: jwk,
})
enforceAudience(payload.aud, 'billing-api')
enforceExpiry(payload.exp, now(), 60)
enforceNotBefore(payload.nbf, now(), 60)
enforceScope(payload.scope, 'transfers:write')
if (await isRevoked(payload.jti)) throw new AuthError('revoked')
return buildPrincipal(payload)
}Several points in that flow deserve emphasis.
Algorithm policy comes from the verifier, not from header.alg
The header can declare. The verifier decides.
Key lookup must be anchored in trusted issuer configuration
Do not let the token tell you which arbitrary JWKS URL to fetch. Do not accept a jku or x5u URL from the token unless you have an extremely tight allow-list and a very good reason. Otherwise token parsing turns into SSRF and key-substitution surface.
Claims are checked after cryptographic authenticity, but before authorisation use
A valid signature over the wrong audience is still the wrong token.
A library can help with mechanics, but policy still lives in your code
JWT libraries are very good at parsing compact serialisations and doing signature maths. They cannot know which issuer, audience, clock skew, or scope your application expects. That part is your responsibility.
Most JWT Vulnerabilities Come from Policy Confusion Around Verification
The famous JWT bugs are worth studying because they all exploit the same deeper problem: the verifier trusted token-controlled metadata too much.
alg: none
Historically, some libraries accepted alg: none and treated the token as valid without a signature. That was catastrophic because the attacker could simply mint arbitrary claims.
Modern mainstream libraries usually fixed this years ago, but the lesson remains: never infer what security work to perform from untrusted token metadata without an allow-list.
RS256 versus HS256 confusion
As discussed earlier, this happens when a verifier is willing to accept both algorithm families and reuses the same key material improperly. Public RSA keys are public. If you let an attacker reinterpret that public key as an HMAC secret, they can forge tokens.
kid injection and path traversal
kid is just a key identifier hint. It should be matched against a trusted in-memory or cached key set.
What naive code sometimes does instead is:
- build a file path from
kid - interpolate
kidinto an SQL query - fetch arbitrary remote metadata using
kid
That turns a convenience field into a filesystem, database, or SSRF attack surface. If the token says kid: ../../keys/prod.pem, the correct answer is not "let me see if that file exists". The correct answer is "unknown key id".
Wrong-issuer acceptance
Shared auth middleware sometimes trusts any signature from any configured identity provider and forgets to pin issuer per route or per service. That lets a token minted for another system pass here, which can be enough for privilege escalation in multi-tenant or multi-product estates.
Audience skipping
This is perhaps the most boring and therefore one of the most common failures. If the service does not check aud, any valid token from the issuer may become a bearer pass for any endpoint that happens to share the same trust root.
Expiry checked loosely or not at all
It is astonishing how much code still validates the signature and then ignores exp because "the gateway already checked it". If you are accepting JWTs directly, check the time bounds directly unless the architecture gives you a stronger reason not to.
Treating decoded payload as trustworthy before verification
Many bugs are not in the final yes-or-no decision. They are in what the code does before that decision. If logging, routing, tenant selection, or template rendering consumes sub, tenant_id, or role from an unverified token, you can still get bad behaviour even if verification later rejects the request.
The recurring lesson is simple: the token format is not dangerous by itself. Ambiguous policy around it is.
JWTs Are Usually the Wrong Default Storage Format for Browser Sessions
This is where architectural discussions get muddled, because people jump from "JWT can represent claims" to "therefore I should put a long-lived JWT in localStorage and use it as my website session".
That move creates two distinct classes of problem.
Browser JavaScript can read localStorage
If your access token lives in localStorage, any XSS bug that executes on your origin can read it and send it away. That is not a theoretical problem. It is one of the cleanest account-takeover paths in real web applications.
By contrast, a cookie marked HttpOnly is not readable by page JavaScript. An attacker with XSS can still make authenticated requests from the victim's browser while the page is open, but straightforward token exfiltration becomes much harder.
JWTs are bearer tokens
Whoever holds the token can use it until it expires, subject to any revocation architecture you built around it. If a browser-side token is stolen, the attacker does not need the browser anymore. They can replay it from anywhere with curl.
CSRF and XSS tradeoffs still exist
Moving sessions to cookies does not magically solve everything. Cookies bring CSRF considerations, which is why SameSite, CSRF tokens, or same-origin API design still matter. But for first-party web apps, the normal answer is still that server-set HttpOnly, Secure, SameSite=Lax or Strict cookies are a better default than a bearer JWT readable by page code.
Machine-to-machine and mobile are different
JWT bearer tokens make more sense in contexts where there is no ambient browser cookie model and where the client can store secrets or tokens in platform-protected storage. A mobile app using OS keystore facilities or a backend calling another backend is not the same threat model as a browser tab loaded with third-party scripts.
So the rule is not "never use JWT". The rule is closer to: do not confuse API bearer-token patterns with first-party browser session patterns.
Revocation Is Where Stateless JWT Dreams Usually Meet Reality
People choose JWTs for statelessness, then immediately need logout, account disable, key compromise response, or permission change propagation. Those requirements pull state back into the design.
Suppose Nikos logs in and gets a JWT valid for 24 hours. Two hours later:
- he logs out
- or his laptop is stolen
- or his role changes from admin to analyst
- or the token leaks in browser storage
If the token is purely self-contained and the verifier consults no server-side state, the token keeps working until exp.
That is the core revocation problem.
Short lifetimes reduce blast radius but do not eliminate it
An access token valid for 10 minutes is much better than one valid for 24 hours. If stolen, the attacker has less time. But ten minutes is still ten minutes of unauthorised access.
Refresh-token rotation moves the problem, which is often the right trade
A common design is:
- short-lived access token
- longer-lived refresh token
- refresh token stored more carefully and rotated on every use
Now access tokens die quickly, while the longer-lived stateful element is the refresh token store. This is a reasonable design, but it is no longer purely stateless.
Revocation lists or token-version checks reintroduce server-side state
If you keep a blacklist keyed by jti, or a per-user session version that must match a claim in the token, the verifier consults state. That is sometimes the correct answer. It just means you gave up the simple story that JWTs eliminate server state.
Opaque sessions are often simpler for websites
A traditional server-side session cookie points to server-held state. Logout means delete or invalidate that state. Privilege change means update the server record. Compromise response is straightforward. For first-party websites this simplicity is often worth more than the portability of self-contained claims.
This is why a lot of mature teams end up with a hybrid view:
- JWTs are useful as access tokens between services and identity systems
- opaque or stateful sessions are often better for browser login state
That is not backsliding. It is aligning the mechanism with the revocation requirement.
Access Tokens, ID Tokens, and Refresh Tokens Solve Different Problems
One reason JWT deployments go wrong is that teams stop distinguishing token types after the first successful demo.
An access token answers one question: what can this client do against this resource server right now?
An ID token answers a different question: what identity assertions did the issuer make during the authentication flow?
A refresh token answers another question again: how can the client come back for a new access token without making the user sign in every few minutes?
Those are different jobs. Mixing them up creates subtle failures that do not always look like outright security bugs at first.
An ID token is not a general-purpose API bearer token
OpenID Connect adds the ID token so the client can confirm who authenticated and under which session context. That token is aimed at the client application. It often contains claims such as:
- user identifier
- issuer
- audience equal to the client id
- authentication time
- ACR or AMR details in stronger deployments
What it does not automatically mean is "send this token to every backend API and treat it as an access token".
That misuse appears in real systems because the ID token is already there and already signed. Teams decode it, see the user's subject claim, and think the job is done. But the audience may be wrong, the scope model may be absent, and the token may be intended only for the client that initiated the login flow.
If the billing API expects an access token minted for billing-api, an ID token whose aud is spa-client should fail even if the signature is perfectly valid.
An access token should be scoped to the resource it reaches
Access tokens are where authorisation detail usually belongs. That may be:
scoperoles- tenant or organisation claims
- resource indicators
- token class or client class
But all of that only helps if the resource server applies it locally and strictly. A good access-token design does not assume every verifier will infer the right policy from generic claims. It makes the intended audience and permission model explicit.
This is one reason API ecosystems often issue several different access tokens in the same user session. One token might be for the profile API, another for billing, another for an internal admin surface. Reusing one wide bearer token everywhere is operationally convenient, but it broadens blast radius when anything leaks.
A refresh token is a session handle with different risk
Refresh tokens are often treated as "just a longer JWT", but operationally they behave more like a high-value session credential.
If an attacker steals a short-lived access token, the damage window may be minutes. If they steal a refresh token that can mint new access tokens for days, the problem is much worse.
That is why mature systems treat refresh tokens differently:
- tighter storage rules
- rotation on every use
- reuse detection
- server-side state
- explicit revocation paths
At that point the architecture has already admitted an important truth: real login systems usually need state somewhere, even if the access token format is self-contained.
Browser, mobile, and backend clients should not share one storage story
A browser SPA, a native mobile app, and a backend worker do not face the same storage risks.
For browsers, the key question is how much of the credential is exposed to page JavaScript and what one XSS bug can steal.
For mobile, the key question is whether the operating system keystore or secure enclave is used correctly and whether refresh credentials are bound to device state.
For backend services, the key question is usually secret distribution, key rotation, and whether service identity should be short-lived and centrally issued instead of copied into environment variables for months.
The token format can stay similar across those environments. The storage and renewal model should not.
The useful rule is simple: token type, audience, and storage model must match the client type and the resource being accessed. If those three drift apart, the design may still work in happy-path demos while quietly failing in production security reviews. That mismatch is one of the fastest ways to turn a tidy token design into a messy incident response problem.
Key Rotation and JWKS Caching Decide Whether Verification Survives Real Operations
Even if every cryptographic and claim check is right, production systems still fail on key management.
kid exists so multiple keys can overlap cleanly
An issuer should be able to publish at least two keys during rotation: current and next. New tokens can start using the new key while verifiers still accept old tokens until they expire.
The kid header tells the verifier which public key in the JWKS to select.
JWKS caching is necessary but dangerous when done lazily
If every request triggers a live JWKS fetch, your auth path now depends on a remote HTTP call and becomes easy to DoS. So verifiers cache keys.
But caches introduce their own failure modes:
- stale key set causes fresh tokens to fail after rotation
- too-eager refresh causes thundering herds
- too-lenient fallback lets attackers keep using retired keys longer than intended
- emergency key revocation becomes slow if caches ignore cache-control and retry policy
A sane approach usually looks like:
- bootstrap issuer metadata from static configuration
- fetch JWKS over HTTPS from a pinned issuer URL
- cache by
kidand respect reasonable TTLs - refresh proactively in background before expiry when possible
- on unknown
kid, trigger one bounded refresh before rejecting - rate-limit refresh attempts so attacker-supplied bogus kids do not create fetch storms
Algorithm and key type must still be pinned after key lookup
A JWK can contain fields like alg, kty, use, and key_ops. Treat them as hints from a trusted issuer's published metadata, not as a replacement for verifier policy. If your service expects RS256 access tokens, do not accept a fetched JWK that nudges you into some other algorithm family.
Emergency rotation needs a story
If a private key is suspected compromised, waiting for all old access tokens to age out may be unacceptable. That means your design needs an emergency response path:
- publish new key immediately
- stop issuing with old key
- optionally reject old
kidimmediately despite token lifetime - communicate outage risk to dependent services
This is another place where short-lived tokens help enormously. Five-minute access tokens make emergency rotation much less painful than 24-hour tokens.
JWTs Are Powerful When You Need Portable Claims and Dangerous When You Need Simplicity
By now the pattern should be visible. JWTs shine when you genuinely need their strengths.
They are good at carrying portable signed assertions
An identity provider can sign a token saying:
- this is subject
user_42 - issued by
https://auth.example.eu - for audience
billing-api - with scope
transfers:write - valid until 10:45 UTC
Any verifier with the right public key can check that assertion locally.
That is extremely useful in distributed systems, federation, API gateways, and SSO flows.
They are not good at erasing application complexity
The moment you need:
- immediate logout
- revocation after compromise
- easy first-party browser session handling
- multi-step storage hardening against XSS
- highly dynamic permission state
JWTs stop being a magic simplifier. You add refresh-token stores, revocation lists, token versioning, scope recalculation, or opaque-session fallbacks. None of that is wrong. It just means the token was only one component of the real system.
Sometimes the boring alternative is better
For a classic web application with one backend and one front end, a server-managed session cookie is frequently the more honest design. For a public API ecosystem or federated identity architecture, signed JWTs are often the right design. The important thing is choosing deliberately.
The lab that accompanies this post walks through exactly that verification pipeline: parse the token, pin the algorithm, resolve kid from a trusted key set, verify the signature, validate claims, then confront the browser-storage and revocation tradeoffs that signatures alone do not solve.
What to Remember When You Review a JWT-Based System
If you are auditing or implementing JWT handling, the checklist is short enough to memorise.
1. Treat header and payload as untrusted until verification finishes
Do not route, authorise, or personalise anything based on them early.
2. Pin issuer, audience, allowed algorithms, and key type in verifier configuration
Do not let the token negotiate those values.
3. Resolve keys from trusted issuer metadata, not from token-supplied URLs or file paths
kid is a lookup hint, not an instruction language.
4. Enforce time claims with sensible skew handling
Signatures without freshness checks are incomplete.
5. Be explicit about revocation architecture
If the answer to "how do we invalidate a stolen token right now?" is vague, the design is not finished.
6. For first-party web apps, prefer cookies over browser-readable bearer tokens
This one design choice prevents a huge class of easy token theft.
7. Keep access tokens short-lived and make rotation routine
The cleaner your rotation story, the less terrifying compromise becomes.
JWTs are not mysterious once you strip away the buzzwords. They are signed or encrypted claim containers with compact serialisation rules. Their safety does not come from the dots. It comes from the verifier being strict about algorithm policy, key resolution, claim interpretation, storage, and revocation.
That is the whole story in one sentence: a JWT is only as trustworthy as the system that verifies and contains it.
If you remember that, most of the famous pitfalls stop looking exotic. alg: none, HS256 versus RS256 confusion, bad kid handling, audience skipping, localStorage theft, and non-existent logout semantics are all just different ways of forgetting the same thing. The token is not the security model. The surrounding protocol, storage choices, and state management are the security model.
Treat JWTs as one tool in that larger system and they are extremely useful. Treat them as a shortcut around that system and they become one more place where authentication logic fails in public.