DEV Community

Cover image for JWT Authentication in Node.js Explained Simply
SATYA SOOTAR
SATYA SOOTAR

Posted on

JWT Authentication in Node.js Explained Simply

Hello readers 👋, welcome to the 8th blog of our Node.js journey!

So far, we've covered how Node.js works under the hood, its event loop, non-blocking code, and how to handle async operations. Now we're going to build something practical that almost every real-world application needs: user authentication using JSON Web Tokens (JWT).

Authentication is how an application knows who you are. You see it every day: logging into a website, accessing private messages, or making a purchase. Today, we'll understand what JWT is, how it works (without diving into complex cryptography), and how to implement a simple, secure login flow in Node.js.

Let's get started.

What authentication means

Authentication is the process of verifying a user's identity. When you log in with a username and password, the server checks those credentials and, if they match, it should remember that you are authenticated for subsequent requests. Without authentication, anyone could pretend to be anyone, and there would be no security.

In traditional web applications, once logged in, the server creates a session and stores it in memory or a database. The client gets a session ID stored in a cookie. Each request sends that cookie, and the server looks up the session. This works, but it's stateful: the server must store session data, which can be a scalability bottleneck if you have multiple servers.

JWT offers a stateless approach. The server doesn't need to remember anything. Instead, after a successful login, the server creates a token that contains user information and signs it. The client stores this token and sends it with every request. The server simply verifies the signature and reads the user data directly from the token. No database lookups for sessions. This makes scaling much easier.

What JWT is

JWT stands for JSON Web Token. It's an open standard (RFC 7519) that defines a compact, self-contained way to transmit information between parties as a JSON object. The information can be trusted because it's digitally signed.

JWT is perfect for authentication. After a user logs in, the server generates a JWT containing their user ID (and maybe role) and sends it to the client. The client sends the token in the Authorization header of every subsequent request. The server checks the signature and extracts the user ID, thus identifying the user without a database session lookup.

Because the token itself contains the user information, it's self-contained. That's the "stateless" magic.

Structure of a JWT

A JWT looks like a long random string of characters with two dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.4x9MMbKd7P3qW7Y8lQZ2VcDhFfR1jNzOyU5wTkXy
Enter fullscreen mode Exit fullscreen mode

This string is actually three parts, each Base64URL-encoded, separated by dots. Let's decode each part conceptually (not deep crypto, just the idea).

Header

The first part is the header. It typically contains two fields: the type of token (JWT) and the signing algorithm used (HS256 or RS256). After Base64 decoding, it looks like:

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Payload

The second part is the payload. This is where the actual data (claims) about the user lives. The server puts information like user ID, username, role, and expiration time here. For example:

{
  "userId": 1,
  "username": "satya",
  "role": "admin",
  "iat": 1680000000,
  "exp": 1680003600
}
Enter fullscreen mode Exit fullscreen mode

The iat (issued at) and exp (expiration) claims are standard. You can add custom data too. Remember: the payload is not encrypted, just Base64-encoded. Anyone can decode it, so never put sensitive info like passwords in it.

Signature

The third part is the signature. This is what makes the token trustworthy. To create the signature, the server takes the encoded header, the encoded payload, and a secret key, then applies the algorithm specified in the header (e.g., HMAC SHA256). The result is a cryptographic hash that ensures the token hasn't been tampered with.

If a malicious user tries to change the payload (e.g., change userId from 1 to 2), the signature will no longer match when the server recalculates it using the secret key. The server detects tampering and rejects the token. That's the core security.

Login flow using JWT

Let's walk through a typical login flow step by step, with code snippets using Node.js, the express web framework (for routing convenience), and the jsonwebtoken library.

1. Install required packages:

npm install express jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

2. Create a simple server with a login endpoint.

const express = require('express');
const jwt = require('jsonwebtoken');

const app = express();
app.use(express.json()); // to parse JSON request body

const SECRET_KEY = 'your-secret-key-change-this'; // in production, use an env var

// In-memory user store (for demo)
const users = [
  { id: 1, username: 'satya', password: 'password123', role: 'admin' }
];

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username && u.password === password);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Create a token. The payload includes the user's id and role.
  // Don't include the password!
  const token = jwt.sign(
    { userId: user.id, role: user.role },
    SECRET_KEY,
    { expiresIn: '1h' } // token expires in 1 hour
  );
  res.json({ token });
});

app.listen(3000, () => console.log('Server running on port 3000'));
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  • We receive username and password.
  • We check against a hardcoded list (in reality, you'd check a database with hashed passwords).
  • If valid, we use jwt.sign() to create a token with a payload containing the user ID and role. The token is signed with our secret key and set to expire in 1 hour.
  • We send the token back to the client as JSON.

3. Client stores the token and sends it with requests.

After login, the client (browser or mobile app) stores the token. In a web app, you might use localStorage or a secure HTTP-only cookie. For simplicity, we'll use the Authorization header with the Bearer scheme.

A subsequent request to a protected route would include the header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode

Protecting routes using tokens

Now we need to protect certain API routes so that only authenticated users can access them. We'll create a middleware that checks the token.

// Middleware to verify JWT
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
  if (!token) {
    return res.status(401).json({ message: 'Access token missing' });
  }

  jwt.verify(token, SECRET_KEY, (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired token' });
    }
    // decoded contains the payload, e.g., { userId: 1, role: 'admin', iat: ..., exp: ... }
    req.user = decoded;
    next(); // proceed to the next middleware/route handler
  });
}
Enter fullscreen mode Exit fullscreen mode

Then we can protect any route by adding this middleware:

app.get('/profile', authenticateToken, (req, res) => {
  // req.user is available from the token
  res.json({ message: `Hello user ${req.user.userId}, your role is ${req.user.role}` });
});

app.get('/admin', authenticateToken, (req, res) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ message: 'Admin access required' });
  }
  res.json({ message: 'Welcome to admin panel' });
});
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. The client sends a request to /profile with the token.
  2. The authenticateToken middleware extracts the token, calls jwt.verify() with the secret key.
  3. If valid, it attaches the decoded payload to req.user and passes control to the actual route handler.
  4. The handler can use req.user.userId or any custom claim.
  5. If the token is invalid (bad signature, expired), the middleware sends a 403 response.

Sending token with requests: from client side

In a Node.js client (or frontend JS), you'd include the token after login:

// After login, store token
const tokenResponse = await fetch('/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ username: 'satya', password: 'password123' })
});
const { token } = await tokenResponse.json();
// Store token (e.g., in localStorage)
localStorage.setItem('token', token);

// Later, for protected requests:
const token = localStorage.getItem('token');
const profileResponse = await fetch('/profile', {
  headers: { 'Authorization': `Bearer ${token}` }
});
Enter fullscreen mode Exit fullscreen mode

In a pure HTTP context (like Postman), you'd manually set the header.

The token validation lifecycle

From the moment the server issues a token to its eventual expiry or logout, the lifecycle is:

  1. Issuance: Server creates and signs token on successful login.
  2. Storage: Client stores token (memory, localStorage, cookie).
  3. Transmission: Client sends token in Authorization header with each request.
  4. Verification: Server middleware verifies signature and expiration. If valid, request proceeds; if invalid, 403.
  5. Expiration: After the exp claim time, the token is rejected even if the signature is correct. This forces the user to re-login.
  6. Logout (optional): Since JWTs are stateless, there's no server-side invalidation. To "logout", you simply remove the token from the client. For added security, you can maintain a token blacklist (stateful), but the stateless beauty is lost.

Visualizing the flow

JWT login authentication flow:

JWT authentication flow

A note on security

  • Always use HTTPS: JWTs are transmitted in headers; without TLS, an attacker could steal the token.
  • Keep the secret key secret: Store it in environment variables, never in code.
  • Set a short expiration time: 15 minutes to 1 hour is typical. Use refresh tokens for longer sessions (beyond this article).
  • Don't put sensitive data in the payload: It's only Base64-encoded, not encrypted.
  • Consider token storage: localStorage is vulnerable to XSS attacks; HttpOnly cookies are more secure but require CSRF protection. The best approach depends on your application.

Conclusion

JWT authentication is a clean, stateless way to secure Node.js APIs. It eliminates server-side session storage, simplifies scaling, and gives you a self-contained token that carries user identity. By understanding the header, payload, and signature, you now know exactly why JWTs are secure and how to implement the full login and route protection flow.

Let's quickly recap:

  • Authentication verifies identity; JWT provides a stateless method by issuing a signed token.
  • A JWT consists of three Base64-encoded parts: header (algorithm), payload (user data), and signature (tamper-proof).
  • The login endpoint validates credentials and returns a JWT made with jsonwebtoken.sign().
  • The client sends the token in the Authorization: Bearer <token> header for subsequent requests.
  • Middleware verifies the token with jwt.verify(), extracts the user payload, and protects routes.
  • This flow is scalable, simple, and widely adopted.

With JWT authentication in your toolbox, you can now build secure, stateless Node.js backends. In the next post, we'll continue building practical features. See you then!


Hope you found this helpful! If you spot any mistakes or have suggestions, let me know. You can find me on LinkedIn and X, where I post more about web development.

Top comments (0)