JWT in APIs: What It Does Well and Where It Falls Short
by Eric Hanson, Backend Developer at Clean Systems Consulting
What JWT actually is
A JSON Web Token (defined in RFC 7519) is a base64url-encoded JSON payload, signed by a key you hold. The signature lets any party with the corresponding verification key confirm that the claims in the token were issued by you and have not been tampered with — without querying a database.
A minimal JWT payload:
{
"sub": "user_01HZQK7P3WVXBN",
"roles": ["orders:read", "orders:write"],
"iat": 1745654400,
"exp": 1745658000
}
sub is the subject (user identifier). iat is issued-at. exp is expiry. The token is signed with either an HMAC-SHA256 secret (symmetric — both issuer and verifier share the secret) or an RSA/EC key pair (asymmetric — the issuer signs with the private key, verifiers use the public key).
The asymmetric approach (RS256, ES256) is strongly preferred for any multi-service architecture. It allows your resource servers to verify tokens without having access to the signing secret, which they would need to forge tokens if it were leaked.
What JWTs do well
Stateless verification. Your API gateway or service can verify a JWT with a single cryptographic operation. No database lookup. No network call to an auth service. At high request volumes (thousands per second per node), this matters — the alternative is a session lookup that adds latency and creates a bottleneck on your auth store.
Embedded claims. The token carries the information the service needs to make authorization decisions: user ID, roles, scopes, tenant ID. The service does not need to look up permissions; they are in the token.
Cross-service trust. In a microservice architecture, service A can issue a JWT that service B accepts, even if they are completely independent systems, as long as both know the issuer's public key. This is clean and scalable.
Where JWTs fall short
Revocation is the central problem. A valid JWT is valid until it expires — full stop. If a user logs out, changes their password, or has their account compromised, you cannot invalidate an outstanding JWT. It will continue to be accepted by any verifier until exp.
The workaround is a token blocklist: a fast store (Redis is the standard choice) that tracks revoked token IDs (jti claim). Every verification step checks the blocklist. This works but reintroduces the network roundtrip you were trying to avoid, and your blocklist becomes a critical-path dependency.
The practical resolution: use short expiry (15 minutes is common for access tokens) and issue long-lived refresh tokens separately. The refresh token is stored server-side and can be revoked. The access token can expire frequently enough that the revocation window is acceptable.
The payload is not encrypted by default. A JWT is signed, not encrypted. Base64url is not encryption — anyone with the token can decode the payload and read the claims. Do not put sensitive data in a JWT unless you are using JWE (JSON Web Encryption, a different spec) to encrypt it.
This matters for API keys passed between services and for tokens stored in browser localStorage. The claims are visible to anyone who can read the token.
Algorithm confusion attacks. The JWT header specifies the algorithm used to sign the token. Early library implementations allowed an attacker to specify "alg": "none" in a crafted token and bypass signature verification entirely. Some libraries also accepted "alg": "HS256" on tokens signed with an RS256 key pair, treating the public key as the HMAC secret.
The fix: always specify the accepted algorithm explicitly in your verification code. Never accept none. Never derive the algorithm from the token header alone.
# Wrong
jwt.decode(token, public_key) # trusts alg from header
# Right
jwt.decode(token, public_key, algorithms=["RS256"]) # explicitly requires RS256
Use python-jose or PyJWT >= 2.0 which default to secure behavior, but verify your library's defaults either way.
Token size. JWTs with many claims grow large. A token with dozens of role and scope claims can easily exceed 1KB. Multiplied across every request, this adds up in network overhead and header parsing time. Keep claims minimal — the token should carry what the service needs, not everything the user has ever been granted.
The libraries worth knowing
- Java:
java-jwt(Auth0) orjjwt(JJWT project). Both support RS256/ES256 and have sane defaults post-2020. - Node.js:
jsonwebtokenfor basic use;jose(by Panva) for more complete JOSE spec support including JWE. - Python:
PyJWT >= 2.0with explicit algorithm specification. - Go:
golang-jwt/jwt(the community-maintained fork after the original was abandoned).
The right mental model
A JWT is a signed, time-limited statement: "at this time, this issuer asserted these claims about this subject." It is not a session. It is not a capability. It is a claim that must be verified structurally (valid signature), temporally (not expired), and contextually (issuer matches what you expect, audience matches your service).
Treat it that way and it is a solid building block. Treat it as a magic ticket and the gaps will bite you.