⏱️ Lectura: 12 min
Every time a browser sends a request to an API and the server responds without having touched the database to figure out who you are, there’s a JWT token doing that job. The acronym stands for JSON Web Token, and today it’s the most widely used mechanism for authenticating users in REST APIs, mobile apps, and microservice architectures.
📑 En este artículo
- TL;DR
- What a JWT token is and why it replaced server-side sessions
- How a JWT token works internally
- The full lifecycle: login, verification, and expiration
- Practical examples with code
- Getting started: implementing JWT step by step
- Real use cases
- Common mistakes and best practices
- JWT vs sessions vs opaque tokens
- Going deeper: revocation, refresh tokens, and signing algorithms
- Frequently Asked Questions
- References
This article explains the exact structure of a JWT token, how to sign and verify it with real Node.js code, when it makes sense to use one, and when a traditional cookie-based session is still the better option.
TL;DR
- You’ll understand the exact structure of a JWT: header, payload, and signature, and why anyone can read it without being able to forge it.
- You’ll implement JWT login in Express using jsonwebtoken, from signing the token to protecting it with middleware.
- You’ll learn why storing a JWT in localStorage is an XSS risk and what alternative to use instead.
- You’ll distinguish JWT from cookie-based sessions and opaque tokens, and when each approach makes sense.
- You’ll learn to verify a token’s validity with the jwt.io debugger and with your own Node script.
- You’ll design a refresh token scheme to revoke access without waiting for the JWT to expire.
- You’ll identify the most common mistake: not validating the signing algorithm and being left exposed to a bypass.
What a JWT token is and why it replaced server-side sessions
Before JWT, the standard way to keep a user authenticated was the server-side session: the user logged in, the server stored a session identifier in memory or in Redis, and sent that identifier to the browser inside a cookie. On every subsequent request, the server had to look up that session in its storage to know who the user was.
A JWT token flips the model: instead of storing state on the server, it stores it inside the token itself, signed. The server issues the token once, and on every future request it only needs to verify the signature mathematically, without querying any database or cache. This is what’s known as stateless authentication.
The format was standardized in IETF’s RFC 7519, and since then it has been the foundation of OAuth 2.0, OpenID Connect, and practically every modern authentication SDK (Auth0, Firebase Auth, AWS Cognito). A JWT doesn’t replace OAuth: OAuth defines how access is authorized between applications, and JWT is frequently the format OAuth uses to represent that access once granted.
How a JWT token works internally
A JWT is a text string with three parts separated by dots: header.payload.signature. Each part is encoded in base64url, not encrypted: anyone with the token can decode and read the content without needing the secret key.
The header declares the signing algorithm and the token type, for example {"alg":"HS256","typ":"JWT"}. The payload contains the claims: data such as the user’s id, their role, and when the token expires (the exp claim). The signature is calculated by applying the algorithm declared in the header to the concatenated header and payload, using a secret (or private) key that only the server knows.
flowchart LR
A["Header: alg and typ"] --> D["Fully signed token"]
B["Payload: claims like sub and exp"] --> D
C["Signature: HMAC or RSA over header and payload"] --> D
The fact that the payload isn’t encrypted is a design decision, not an oversight: it lets any intermediate service (an API Gateway, for example) read the user’s role without needing the signing key, which is useful in microservice architectures. If you need to hide the content, the standard defines JWE (JSON Web Encryption) as an additional layer, though in practice almost no one uses it because sensitive data simply shouldn’t travel inside a JWT.
⚠️ Heads up: never put passwords, card numbers, or sensitive data inside a JWT’s payload. Anyone who intercepts the token (or pastes it into jwt.io) can read it without decoding anything special.
The full lifecycle: login, verification, and expiration
sequenceDiagram
participant U as User
participant S as Authentication server
participant R as Protected API
U->>S: sends username and password
S-->>U: responds with signed JWT and exp in 15 minutes
U->>R: requests resource with the JWT in the Authorization header
R-->>U: verifies the signature and returns the data
Note over U,R: the server doesn't query the database to validate the token
The exp claim defines a Unix timestamp after which the token stops being valid. When it expires, the server responds with a 401 and the client needs to request a new one, typically using a longer-lived refresh token that’s exchanged for a fresh JWT without asking the user for their password again.
The expiration window is the main security lever in a JWT-based system: the shorter it is, the less time a stolen token stays exposed, but the more refresh requests the authentication server has to handle.
Practical examples with code
The first example shows something important: decoding a JWT doesn’t require any library or the secret key, because the payload isn’t encrypted.
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFuYSJ9.dGVzdC1zaWduYXR1cmU";
const [header, payload] = token.split(".");
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
console.log(decodedPayload);
// { sub: '1234', name: 'Ana' }
This code splits the token by its dots and decodes the second part from base64url to JSON. The result shows the claims as-is, without having verified whether the signature is valid: that’s why decoding never replaces verifying.
The second example does verify the signature, using Auth0’s jsonwebtoken library in a real Express server:
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET;
app.post("/login", (req, res) => {
const usuario = autenticarUsuario(req.body);
const token = jwt.sign(
{ sub: usuario.id, rol: usuario.rol },
SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
res.json({ token });
});
function requiereAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.replace("Bearer ", "");
try {
req.usuario = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
next();
} catch (err) {
res.status(401).json({ error: "invalid or expired token" });
}
}
app.get("/perfil", requiereAuth, (req, res) => {
res.json({ id: req.usuario.sub, rol: req.usuario.rol });
});
The /login route signs a 15-minute token with the user’s id and role. The requiereAuth middleware reads the Authorization: Bearer <token> header, verifies the signature with jwt.verify, and if it’s valid, lets the request through with req.usuario already available in /perfil. Note that algorithms: ["HS256"] is passed explicitly: that’s the line that prevents the bypass explained further below.
Getting started: implementing JWT step by step
To reproduce the previous example in a new Node.js project, the steps are:
npm install express jsonwebtoken
Next, you need to define the environment variable with the signing secret, never hardcoded in the code:
JWT_SECRET=a-long-random-secret-of-at-least-32-bytes
With that, you’re set to spin up the example’s two routes (/login and /perfil) and test them with curl:
curl -X POST http://localhost:3000/login -d '{"user":"ana"}' -H "Content-Type: application/json"
curl http://localhost:3000/perfil -H "Authorization: Bearer RECEIVED_TOKEN"
To confirm what a token contains without writing code, you can paste it into the jwt.io debugger, which shows the decoded header and payload and validates the signature if you give it the secret. To confirm it from the terminal, without depending on an external site:
node -e "console.log(require('jsonwebtoken').decode(process.argv[1]))" $TOKEN
That command decodes the token passed as an argument and prints its claims to the console, useful for debugging in scripts without opening a browser.
Real use cases
JWTs are used mainly in three scenarios: APIs consumed by mobile apps (where browser cookies aren’t natively available), microservice architectures (where each service verifies the token independently without sharing a central session store), and Single Sign-On schemes like OpenID Connect, where an identity provider issues the token once and several different applications accept it.
In all three cases the pattern is the same: avoid having each service make a network call to a shared session store just to find out who made the request.
Common mistakes and best practices
The most commonly cited mistake in JWT security documentation is accepting the none algorithm: some old libraries allowed an unsigned token if the header declared "alg":"none". An attacker could edit the payload, remove the signature, and the server would still accept it as valid if it didn’t explicitly restrict which algorithms it expected. That’s why this article’s code example passes algorithms: ["HS256"] when verifying, instead of trusting what the token itself claims in its header.
The second common mistake is where the token is stored in the browser. Storing it in localStorage is convenient, but any script that manages to inject itself via XSS can read it directly with JavaScript. The OWASP guide on JWT recommends an httpOnly and secure cookie, which JavaScript can’t read even if there’s an active injection.
💡 Tip: combine a short-lived JWT (5 to 15 minutes) with a long-lived refresh token stored in an httpOnly cookie. This limits the damage of a stolen token without forcing the user to log in again all the time.
A third mistake is not setting an expiration: a JWT without exp is valid forever, which nullifies any later revocation strategy.
JWT vs sessions vs opaque tokens
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| JWT (stateless) | Distributed APIs, mobile apps, microservices | Doesn’t need to query the database on every request | Can’t be revoked before it expires without a blacklist |
| Cookie-based session | Server-rendered monolithic apps | Immediate revocation: just delete the session from the store | Requires querying Redis or the database on every request |
| Opaque token (reference) | When instant revocation matters more than avoiding the lookup | Invalidates instantly, exposes no claims if leaked | Needs a lookup on every verification, just like a session |
Going deeper: revocation, refresh tokens, and signing algorithms
stateDiagram-v2
[*] --> Issued
Issued --> Valid
Valid --> Expired: exp claim is reached
Valid --> Revoked: added to a blacklist in redis
Expired --> [*]
Revoked --> [*]
JWT’s most cited limitation is exactly that: once signed, it’s valid until it expires, and the server has no native way to invalidate it earlier. The three real strategies to mitigate this are keeping a blacklist of revoked tokens in Redis (queried only by whoever needs to check explicit revocation, not on every request), using short expirations combined with revocable refresh tokens, or rotating the entire signing secret, which invalidates absolutely every issued token at once.
On the signing algorithm: HS256 uses a single secret shared between whoever signs and whoever verifies, simple but requiring every service that verifies the token to know that secret. RS256 uses a public/private key pair: only the authentication server has the private key to sign, and any other service can verify with the public key without being able to forge a new token. In systems with multiple microservices verifying tokens, RS256 avoids distributing a shared secret to all of them.
📖 Summary on Telegram: View summary
Your next step: clone this article’s Express example, add a refresh token endpoint with an httpOnly cookie, and see what happens when you let the JWT expire on purpose.
Frequently Asked Questions
Is a JWT encrypted?
No. By default a signed JWT (JWS) is only base64url-encoded and signed, not encrypted: anyone can read its content by decoding it. If you need to hide the data, the standard defines JWE (JSON Web Encryption) as an additional layer.
Where should I store the JWT in the browser?
In an httpOnly and secure cookie, not in localStorage. localStorage is accessible from JavaScript, so an XSS attack can steal the token directly.
How do I revoke a JWT before it expires?
JWT doesn’t support native revocation. The options are keeping a blacklist of invalidated tokens in Redis, using short expirations with refresh tokens, or rotating the signing secret, which invalidates all tokens at once.
Which signing algorithm should I use, HS256 or RS256?
HS256 uses a shared secret, useful when the same service signs and verifies. RS256 uses a public/private key pair, ideal when several services need to verify the token without being able to sign a new one.
Why is it dangerous to accept the none algorithm?
Some old libraries allow an unsigned JWT if the header declares alg: none. An attacker can edit the payload, remove the signature, and the server accepts it as valid if it doesn’t explicitly restrict which algorithms it expects when verifying.
References
- RFC 7519: the official JSON Web Token specification published by the IETF.
- jwt.io: interactive debugger for decoding and verifying JWT tokens.
- OWASP JSON Web Token Cheat Sheet: security best practices guide for JWT.
- auth0/node-jsonwebtoken: repository of the library used in this article’s examples.
- MDN: Authorization header: documentation for the HTTP header used to carry the JWT.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
0 Comments