---
title: "A Shared JWT Login Token for Node.js, PHP, Python and C#"
url: https://phatdev.net/en/blog/jwt-across-languages
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/jwt-across-languages
markdown: https://phatdev.net/en/blog/jwt-across-languages.md
---

# 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.

Categories: Security | Published: 2026-10-19 | Level: Intermediate | 6 min read

Tags: jwt, hs256, nodejs, php, python, csharp, auth

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](https://phatdev.net/uploads/blog/jwt-across-languages/flow-1920.webp)

## 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.

![Two places to keep a token: an open shelf and a locked steel vault](https://phatdev.net/uploads/blog/jwt-across-languages/vault-1920.webp)

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:

```js
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:

```python
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:

```php
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.

```csharp
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](https://phatdev.net/en/blog/jwt-rs256-key-rotation). If you need to hide data rather than just sign it, see [Encrypting Sensitive Data the Right Way with AES-GCM](https://phatdev.net/en/blog/aes-gcm-done-right).

## References

- [RFC 7519 - JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519)
- [RFC 8725 - JSON Web Token Best Current Practices](https://www.rfc-editor.org/rfc/rfc8725)
- [RFC 7518 - JSON Web Algorithms (JWA), mục 3.2 HMAC](https://www.rfc-editor.org/rfc/rfc7518#section-3.2)
- [jose (panva) - tài liệu chính thức](https://github.com/panva/jose)
- [PyJWT - tài liệu chính thức](https://pyjwt.readthedocs.io/)
- [firebase/php-jwt](https://github.com/firebase/php-jwt)
- [Microsoft Learn - JwtSecurityTokenHandler và TokenValidationParameters (.NET)](https://learn.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.jwt.jwtsecuritytokenhandler)

## Related posts

- [Rotating Login Keys Without Logging Anyone Out](https://phatdev.net/en/blog/jwt-rs256-key-rotation.md)
- [Encrypting Sensitive Data the Right Way with AES-GCM](https://phatdev.net/en/blog/aes-gcm-done-right.md)
- [What Is a Passkey? Passwordless Sign-In Explained, and Whether Your Website Should Add It](https://phatdev.net/en/blog/passkeys-explained.md)
