A Shared JWT Login Token for Node.js, PHP, Python and C#

One login token across services written in different languages: what a JWT is, what to check, where to keep it safely, with code run across three languages.

Phát LâmPublished Updated 6 min readIntermediate
A Shared JWT Login Token for Node.js, PHP, Python and C#
On this page
  1. What a JWT login token is
  2. How to do it
  3. For those who write code
  4. Mistakes I see
  5. What to do next

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.

One token passing through four services written in four languages, each confirming it as valid
One token, four languages, the same verification rules.

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.

  1. 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.
  2. When issuing a ticket, set iss, aud and exp, and keep the lifetime short, around 15 minutes.
  3. When checking, tell the library to accept only HS256 and to match iss and aud. Do not let the ticket name its own algorithm and have the library follow along.
  4. Allow clocks to drift by a few seconds, not a few minutes.
  5. For a web app, keep the ticket in a cookie with HttpOnly, Secure and SameSite=Lax. Do not use localStorage: any JavaScript running on the page can read it, including code from a compromised third-party library. JavaScript cannot read an HttpOnly cookie.
  6. If the API is called from a phone app or another service, use the Authorization: Bearer header, and let the client keep the ticket in the platform's secure storage.
Comparison of an open glass shelf and a locked steel vault for storing a token
localStorage is like an open shelf, an HttpOnly cookie is like a locked vault.

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.

Bảo mật full-stackNode.jsPHPPythonjwths256nodejsphppythoncsharpauth

References

  1. RFC 7519 - JSON Web Token (JWT)
  2. RFC 8725 - JSON Web Token Best Current Practices
  3. RFC 7518 - JSON Web Algorithms (JWA), mục 3.2 HMAC
  4. jose (panva) - tài liệu chính thức
  5. PyJWT - tài liệu chính thức
  6. firebase/php-jwt
  7. Microsoft Learn - JwtSecurityTokenHandler và TokenValidationParameters (.NET)