The login service is written in Node.js, the API runs on PHP, and then someone adds an analytics job in Python. The first question the team asks is not "what is a JWT". It is: if a user signs in here, will the other side accept them? Yes, as long as every side follows the same rules. This post lays out those rules and shows how to set them up safely.

What a JWT login token is
Picture an entry ticket. It carries your name, your role, the time it expires, and a stamp from the organiser. The guard at any gate can tell a real ticket from a fake by the stamp, without phoning the office.
A JWT (JSON Web Token) is that ticket. It is a string in three parts separated by dots: a description, the content, and the stamp (the signature). The first two are only re-written into other characters, not hidden, so anyone holding the ticket can read them. The stamp guarantees two things only: the content was not edited, and the ticket came from whoever holds the key. So never put a password or a card number inside a ticket.
Four lines in the content must be checked by anything that accepts a ticket: the expiry (exp), the time it becomes valid (nbf), who issued it (iss), and which service it is meant for (aud). The last one gets forgotten most. If the whole system shares one key, a ticket issued for service A can be carried over to service B, unless B only accepts tickets with B's name on them.
How to do it
Six steps, in order.
- Pick the signing style. The simple one is HS256, a shared secret: the issuer and the checker hold the same key. It suits cases where you control both sides. Make the key at least 32 random bytes.
- When issuing a ticket, set
iss,audandexp, and keep the lifetime short, around 15 minutes. - When checking, tell the library to accept only HS256 and to match
issandaud. Do not let the ticket name its own algorithm and have the library follow along. - Allow clocks to drift by a few seconds, not a few minutes.
- For a web app, keep the ticket in a cookie with
HttpOnly,SecureandSameSite=Lax. Do not uselocalStorage: any JavaScript running on the page can read it, including code from a compromised third-party library. JavaScript cannot read anHttpOnlycookie. - If the API is called from a phone app or another service, use the
Authorization: Bearerheader, and let the client keep the ticket in the platform's secure storage.

One caveat: a plain JWT cannot be withdrawn before it expires. The shorter the lifetime, the smaller the damage if one leaks. For long sessions, add a separate refresh token.
For those who write code
I signed in Node.js and verified in Node.js, Python and PHP. Then I swapped roles: signed in Python, signed in PHP, verified in all three. Nine out of nine were accepted, with sub and role reading back identically. Compatibility takes three things: the same secret turned into bytes the same way (UTF-8 of the string, and never base64-decoded on one side only), time fields as whole seconds, and server clocks synced through NTP.
Node.js with the jose library:
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET); // at least 32 bytes
const token = await new SignJWT({ role: 'editor' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject('user-42').setIssuer('https://auth.example.test')
.setAudience('https://api.example.test').setIssuedAt().setExpirationTime('15m')
.sign(secret);
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.test',
audience: 'https://api.example.test',
clockTolerance: 5,
});
Python with PyJWT. The algorithms argument is mandatory when decoding, which is the right design:
import jwt
claims = jwt.decode(
token, secret, algorithms=["HS256"],
issuer="https://auth.example.test", audience="https://api.example.test",
leeway=5, options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]},
)
PHP with firebase/php-jwt. This library checks exp and nbf but not iss and aud, so you compare those yourself:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
JWT::$leeway = 5;
$c = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
if (($c->iss ?? null) !== 'https://auth.example.test') throw new UnexpectedValueException('bad iss');
if (!in_array('https://api.example.test', (array) ($c->aud ?? []), true)) throw new UnexpectedValueException('bad aud');
For C#, I wrote it from Microsoft's documentation and have not run it, because my machine has no .NET. Two things to remember: ClockSkew defaults to 5 minutes, which is very loose, so lower it to a few seconds; and set MapInboundClaims = false so sub and role keep their names.
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(token, new TokenValidationParameters {
ValidateIssuerSigningKey = true, IssuerSigningKey = key,
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
ValidateIssuer = true, ValidIssuer = "https://auth.example.test",
ValidateAudience = true, ValidAudience = "https://api.example.test",
ValidateLifetime = true, RequireExpirationTime = true,
ClockSkew = TimeSpan.FromSeconds(5),
}, out _);
I also tried four bad tickets: one declaring the algorithm none (no signature), an expired one, one made out for another service, and one signed with a different key. Node, Python and PHP rejected all four, each with a differently named error that means the same thing. I ran the cookie header on a small Node server too and saw exactly this:
Set-Cookie: access=<jwt>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
With cookies you need to think about CSRF (a foreign page quietly sending a request on your behalf). SameSite=Lax stops most of it, and for important write actions you should add a separate CSRF token.
Mistakes I see
The mistakes I see most are in how the library is used, not in the library. Some people set the key to secret or 123456, which a dictionary attack cracks in seconds. Others issue tickets that live 30 days for convenience, then cannot take one back when it leaks. On the PHP side, aud often goes unchecked because people assume the library does it. And some put an address or phone number inside the ticket because they think it is encrypted. It is not.
What to do next
Before going live, check: the algorithm is pinned everywhere you verify, exp, nbf, iss and aud are all there, the key is long and kept out of source code, tickets are short-lived, and web tickets sit in an HttpOnly cookie. When you have several services and do not want to hand the signing secret to all of them, move to a key pair, where the checker holds only the public key. I cover that in Rotating Login Keys Without Logging Anyone Out. If you need to hide data rather than just sign it, see Encrypting Sensitive Data the Right Way with AES-GCM.



