Skip to content
Go back

How to effectively do authentication with JWT

Published:  at  08:36 PM

Table of Contents

Open Table of Contents

Introduction

I’ve learned about JWT authentication many times, but without practicing it, I often forgot the details when it came time to re-implement it. This post aims to be the definitive guide on implementing JWT authentication, specifically using access tokens and refresh tokens, providing a clear, working approach.

Note

Please, don't take it as the only way to implement JWT authentication. This is just a guide to help you understand the process and how to implement it. There are many other ways to implement JWT authentication, and you should choose the one that best suits your needs.

Before diving into the implementation, let’s understand the core problem we’re trying to solve: Authentication.

Authentication

First, what exactly is authentication?

People often confuse authentication with authorization. Let’s clarify:

While distinct, authentication and authorization often go hand-in-hand. When you identify who is requesting a resource, you usually also want to know if they have permission to access it. So, in practice, you’re likely handling both simultaneously.

In this article, we won’t dive deep into authorization. Instead, we’ll focus on a practical example of implementing authentication using JavaScript (technically TypeScript, but don’t worry, it won’t bite).


What is JWT?

JWT stands for JSON Web Token. It’s a widely used, open standard for securely transmitting information between parties as a JSON object, and it’s commonly used for user authentication as well.

JWT addresses the challenge of stateless authentication, a common requirement for many modern applications where the server doesn’t maintain session data between requests. Instead, all necessary user information is contained within the token itself. This makes scaling much easier, as any server can process a request without needing shared session state.

A JWT is composed of three parts, separated by dots (.):

  1. Header: Contains metadata about the token, such as its type (JWT) and the signing algorithm used (e.g., HMAC SHA256 or RSA).
  2. Payload: Holds the actual data, known as “claims”. These can include user ID, name, email, roles, and other custom data.
  3. Signature: A cryptographic hash of the header and payload. It’s generated using a secret key (or a private key if using asymmetric algorithms).

For example, here’s a sample JWT that was created when I logged in to my Vercel account:

Token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidGl0bGUiOiJZb3UgdGhvdWdodCIsImRlc2NyaXB0aW9uIjoiSnVzdCBiZWNhdXNlIHlvdSB0b29rIHlvdXIgdGltZSB0byBjaGVjayBpZiBpdCB3YXMgYSByZWFsIHRva2VuLCBJIGhhdmUgYSBzcGVjaWFsIGdpZnQgZm9yIHlvdTogaHR0cHM6Ly9kdWIuc2gvWW15QjdOZSIsImlhdCI6MTUxNjIzOTAyMn0.ApAj_Uxzc-Y0OLjcHpu3BZFc3nt2rz1r5o4T_IdciWM

Header

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload

eyJzdWIiOiIxMjM0NTY3ODkwIiwidGl0bGUiOiJZb3UgdGhvdWdodCIsImRlc2NyaXB0aW9uIjoiSnVzdCBiZWNhdXNlIHlvdSB0b29rIHlvdXIgdGltZSB0byBjaGVjayBpZiBpdCB3YXMgYSByZWFsIHRva2VuLCBJIGhhdmUgYSBzcGVjaWFsIGdpZnQgZm9yIHlvdTogaHR0cHM6Ly9kdWIuc2gvWW15QjdOZSIsImlhdCI6MTUxNjIzOTAyMn0

Signature

ApAj_Uxzc-Y0OLjcHpu3BZFc3nt2rz1r5o4T_IdciWM

Why is the Signature Important?

The signature is crucial for ensuring the token’s integrity. When a JWT is received, the server uses its secret key to verify that the token hasn’t been altered and was indeed issued by a trusted source. If someone tries to tamper with the header or payload, the signature validation will fail, and the token will be rejected.

This makes JWT a powerful tool for building secure authentication systems, as it prevents malicious actors from forging or modifying tokens.


Diagramming the Flow

Now that we understand what we want to achieve and what JWT is, let’s visualize the process at a high level.

The core problem is simple: we need a reliable way to identify who is trying to access a resource. This resource could be anything, a file, sensitive data, etc.; its specific nature doesn’t matter here.

One common and secure approach is to use access tokens and refresh tokens. You might wonder, what are these tokens, and why do we need both?

Access tokens are used to identify the user making a request to a protected resource. Refresh tokens are used to “renew” a user’s access. We need this “renewal” mechanism for security. We don’t want users to remain logged in indefinitely without interaction because long-lived sessions are more vulnerable to session hijacking, where an attacker could obtain a token and impersonate the user for an extended period.

Another benefit of refresh tokens is that when a user requests a token refresh, your server receives information like their IP address and device details. This data can be used to detect suspicious activity, adding another layer of security.

The general approach involves creating a refresh token every time an access token is issued. This refresh token is only visible to the server and is stored securely.

When a user requests a private resource, we validate their access token. If the access token has expired, we allow the client to request a new access token using their refresh token via a dedicated endpoint.

Authentication Flow

Here’s a visual representation of the initial authentication flow:

Authentication Flow

What’s happening in the diagram:

  1. You attempt to access a private resource.
  2. Since you’re unauthenticated, the server returns a 401 Unauthorized status code.
  3. To authenticate, you send your credentials (e.g., email and password) to the server.
  4. The server validates your credentials and, if valid, returns a JWT access token. You’ll typically store this access token in-memory on the client-side for future requests. The server also generates a refresh token and saves it in a server-side only HTTP-only cookie.
  5. For subsequent requests to private resources, you include the access token (usually in the Authorization header).
  6. The server validates the access token and, if valid, returns the requested resource.

In this diagram, the refresh token isn’t actively used. This might make you wonder why we need it.

Remember when I said, “if their token is expired, we allow them to refresh their access token…”? That’s because access tokens are usually short-lived (typically 15 minutes), while refresh tokens are long-lived (depending on your use case, they might last a day, a week, a month, or longer). We cannot rely on the access token to be valid for an extended period.

Requiring the user to re-authenticate every 15 minutes would be a terrible user experience. This is where the refresh token comes in. The refresh token proves to the server that you’re still the same authenticated user, even if your access token has expired. Instead of fully re-authenticating, you can simply refresh your access token using your refresh token.

Refresh Flow

Here’s a visual representation of the token refresh flow:

Refresh Flow

What’s happening in the diagram:

  1. You were successfully authenticated and able to access your private resources.
  2. Time passes (e.g., 15 minutes), and your access token expires.
  3. You send another request to the server for a private resource, and you receive a 401 Unauthorized status code.
  4. You then call a dedicated endpoint to refresh your access token. The browser automatically sends the HTTP-only refresh token cookie.
  5. The server validates your refresh token. If valid, it generates and returns both a new access token and a new refresh token. The new refresh token is again saved in a server-side only HTTP-only cookie, replacing the old one.
  6. You store the new access token in-memory on the client-side, ready to access private resources again.

By issuing a new refresh token with each refresh, you implement a “sliding window” for sessions. This is a security best practice: if an old refresh token is compromised, it becomes invalid after the next refresh, limiting the attacker’s window of opportunity.


Implementation

Now that we understand the flow, let’s implement it!

For this implementation, we’ll use Fastify, a fast and lightweight web framework for Node.js. Fastify provides a great API for everything we’ll need in this example:

  1. Creating a server
  2. Defining different routes
  3. A great plugin for cookie handling (@fastify/cookie)
  4. A great plugin for JWT operations (@fastify/jwt)

We’ll create a private route that requires a valid access token. We’ll also build two other routes: one for initial authentication (login) and one for refreshing the access token. To keep the example simple, we won’t implement a user registration route, but you could easily integrate it with your preferred database.

First, let’s set up our package.json with all the necessary packages for this demo:

{
  "name": "fastify-jwt-auth-example",
  "version": "1.0.0",
  "description": "Simple Fastify JWT authentication example",
  "main": "index.js",
  "scripts": {
    "start:dev": "tsx watch index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "author": "Nicholas Costa",
  "dependencies": {
    "@fastify/cookie": "^11.0.2",
    "@fastify/jwt": "^9.1.0",
    "fastify": "^5.3.3",
    "typescript": "^5.8.3"
  },
  "devDependencies": {
    "@types/node": "^24.0.1",
    "tsx": "^4.20.3"
  }
}

Now, install these dependencies:

npm install

Step 1: Setting Up the Server

Let’s create our main server file, index.ts, and begin with the basic setup:

import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import { randomUUID } from 'node:crypto';

const fastify = Fastify({ logger: true });

// Register plugins
fastify.register(cookie);
fastify.register(jwt, {
  secret: 'your-jwt-secret-key' // In production, use environment variables
});

Here’s what’s happening:

  1. We’re importing Fastify along with FastifyRequest and FastifyReply types for better TypeScript support, and the two necessary plugins: @fastify/cookie for handling cookies and @fastify/jwt for JWT operations.
  2. We’re creating a Fastify instance with logging enabled, which will help us see what’s happening.
  3. We’re registering both plugins. The JWT plugin requires a secret key that will be used to sign and verify our tokens.
  4. We’re importing the randomUUID function from the node:crypto module to generate unique IDs for our tokens.

Important note: In a real application, never hardcode your JWT secret. Always use environment variables to keep it secure and separate from your codebase!


Step 2: Setting Up Our Constants and Storage

Next, let’s add some constants and a simple in-memory storage for our demo:

// Fixed credentials for demo
const VALID_EMAIL = 'john@doe.com';
const VALID_PASSWORD = '123456';

// In-memory store for refresh tokens (in production, use a database like Redis or PostgreSQL)
// In a real application, you would associate these tokens with a user ID and manage their revocation.
const refreshTokens = new Set<string>();

For this example, we’re using hardcoded credentials for simplicity. In a real application, you’d validate these against a database with properly hashed and salted passwords.

The refreshTokens set will temporarily store our refresh tokens. In a production environment, you would store these in a persistent database like Redis (for fast lookups) or PostgreSQL, typically associating them with a specific user and potentially a device ID for better session management and revocation.


Step 3: Creating Helper Functions

Let’s create a helper function to generate our tokens:

// Helper function to generate tokens
function generateTokens(userId: string) {
  const accessToken = fastify.jwt.sign(
    { userId, type: "access", id: randomUUID() },
    { expiresIn: '15m' }
  );

  const refreshToken = fastify.jwt.sign(
    { userId, type: 'refresh', id: randomUUID() },
    { expiresIn: '30d' }
  );

  return { accessToken, refreshToken };
}

// Helper function to remove a token from the refreshTokens set
function removeRefreshToken(refreshToken: string) {
  refreshTokens.delete(refreshToken);
}

This function creates both tokens with distinct expiration times:

Note

We're using a Set to store the refresh tokens. This is a simple but effective way to store the refresh tokens, preventing duplicates and making it easy to check if a token is in the set.

Warning

Notice that we're also adding an ID to the tokens. This is a simple way to ensure each token is unique. Without the ID, creating two tokens at the same second would result in identical tokens, since the hash is generated from the header and payload, if those are the same, the hash will be the same as well.


Step 4: Creating the Authentication Middleware

Now, let’s create a middleware function to protect our private routes. We’ll create an async function called verifyJwt to validate the access token using the jwtVerify method provided by @fastify/jwt.

async function verifyJwt(request: FastifyRequest, reply: FastifyReply) {
  try {
    await request.jwtVerify();
  } catch (error) {
    return reply.status(401).send({ message: "Unauthorized" });
  }
}

The @fastify/jwt plugin automatically adds a jwtVerify() method to the request object. When called, it will:

  1. Look for the JWT token in the Authorization header (e.g., Bearer <token>).
  2. Verify the token’s signature using the secret key.
  3. Parse the payload and attach it to request.user (e.g., request.user.userId will be user123).

If any of these steps fail (e.g., token missing, invalid signature, expired token), it throws an error. We catch this error and return a 401 Unauthorized response to the client.


Step 5: The Authentication Route

Time to create our /auth route, which users will hit to authenticate themselves:

// Route 1: Authentication
fastify.post('/auth', async (request: FastifyRequest, reply: FastifyReply) => {
  const { email, password } = request.body as { email: string; password: string };

  // Simple credential check (replace with database validation in production)
  if (email !== VALID_EMAIL || password !== VALID_PASSWORD) {
    return reply.status(401).send({ message: 'Invalid credentials' });
  }

  // Generate tokens
  const { accessToken, refreshToken } = generateTokens('user123');

  // Store refresh token (in a real app, this would be in a DB, associated with a user)
  refreshTokens.add(refreshToken);

  // Set refresh token as httpOnly cookie
  reply.setCookie('refreshToken', refreshToken, {
    httpOnly: true, // Prevents client-side JavaScript from accessing the cookie
    secure: false, // Set to true in production with HTTPS for secure transmission
    sameSite: 'strict', // Protects against CSRF attacks
    maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds
  });

  return { accessToken };
});

Let’s break down what’s happening here:

  1. Credential validation: We check if the provided email and password match our hardcoded values. In a real application, this would involve querying a user database and comparing hashed passwords.
  2. Token generation: If credentials are valid, we generate both an access token and a refresh token for the user.
  3. Refresh token storage: We store the refresh token in our in-memory set. We also set it as an HTTP-only cookie in the response.
  4. Response: We return only the access token to the client. The refresh token is kept secure in the HTTP-only cookie.

Why do we set the refresh token as an HTTP-only cookie? This is an important security measure. HTTP-only cookies cannot be accessed by client-side JavaScript, which significantly protects against XSS (Cross-Site Scripting) attacks. Even if an attacker injects malicious JavaScript into your page, they won’t be able to steal the refresh token. Additionally, setting sameSite: 'strict' helps mitigate CSRF (Cross-Site Request Forgery) attacks by preventing the browser from sending the cookie with cross-site requests.


Step 6: The Refresh Route

Now let’s create the /auth/refresh route, which will be used to refresh the access token when it expires:

// Route 2: Refresh token
fastify.post('/auth/refresh', async (request: FastifyRequest, reply: FastifyReply) => {
  const refreshToken = request.cookies.refreshToken;

  // 1. Check if refresh token exists and is known to the server
  if (!refreshToken || !refreshTokens.includes(refreshToken)) {
    return reply.status(401).send({ message: 'Invalid refresh token' });
  }

  try {
    // 2. Verify the refresh token
    const decoded = fastify.jwt.verify(refreshToken) as { userId: string; type: string };

    // 3. Ensure it's explicitly a refresh token (prevents using an access token here)
    if (decoded.type !== 'refresh') {
      return reply.status(401).send({ message: 'Invalid refresh token type' });
    }

    // 4. Invalidate the old refresh token (single-use refresh tokens for better security)
    removeRefreshToken(refreshToken);

    // 5. Generate new access and refresh tokens
    const { accessToken, refreshToken: newRefreshToken } = generateTokens(decoded.userId);

    // 6. Store the new refresh token
    refreshTokens.add(newRefreshToken);

    // 7. Set the new refresh token as an httpOnly cookie, replacing the old one
    reply.setCookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: false, // Set to true in production with HTTPS
      sameSite: 'strict',
      maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
    });

    return reply.status(200).send({ accessToken });
  } catch (error) {
    // Catch errors during JWT verification (e.g., token expired, tampered)
    return reply.status(401).send({ message: 'Invalid refresh token' });
  }
});

This route performs several crucial steps:

  1. Retrieves the refresh token from the HTTP-only cookie sent by the browser.
  2. Validates the token’s existence and checks if it’s in our stored refreshTokens set (this is where a database lookup would occur in a real app).
  3. Verifies the JWT’s signature and expiration, then checks if its payload type is 'refresh'. This prevents an expired access token from being mistakenly used as a refresh token.
  4. Removes the used refresh token from our storage to enforce a single-use token pattern. This limits the window in which a stolen token could be reused. While this doesn’t stop an attacker from using a token if they get to it first, it ensures the same token can’t be used more than once.
  5. Generates new access and refresh tokens. Since the user successfully proved they are still active with a valid refresh token, we issue fresh tokens to extend their session.
  6. Stores the newly generated refresh token.
  7. Sets the new refresh token as an HTTP-only cookie, replacing the old one.
  8. Returns only the new access token to the client.

Step 7: The Protected Route

Finally, let’s create our /private route that requires authentication via a valid access token:

// Route 3: Protected route
fastify.get('/private', {
  preHandler: [verifyJwt] // Use our custom verifyJwt function
}, async (request: FastifyRequest, reply: FastifyReply) => {
  // request.user is populated by fastify.jwt.verify()
  const user = request.user as { userId: string; iat: number; exp: number };
  return {
    message: 'This is a protected route!',
    user: user
  };
});

The magic here is in the preHandler option. This tells Fastify to execute our verifyJwt function before the main route handler. If verifyJwt throws an error (because the token is missing, invalid, or expired), the route handler never runs, and a 401 Unauthorized response is sent. If verifyJwt succeeds, request.user will be populated with the decoded JWT payload, allowing you to access user information within your route handler.


Step 8: Starting the Server

Let’s add the code to start our Fastify server:

// Start server
const start = async () => {
  try {
    await fastify.listen({ port: 8080, host: '0.0.0.0' });
  } catch (err) {
    process.exit(1);
  }
};

start();

Step 9: Testing Our Implementation

Now, let’s test our JWT authentication system!

First, start the server using the start:dev script we defined in package.json:

npm run start:dev

Test 1: Try accessing the protected route without authentication

Open a new terminal and run:

curl -X GET http://localhost:8080/private

You should receive a 401 Unauthorized response:

{"message": "Unauthorized"}

Perfect! Our protection is working as expected.

Test 2: Authenticate and get an access token

Now, let’s log in. This command will send your credentials and save the HTTP-only refresh token to a file named cookies.txt:

curl -X POST http://localhost:8080/auth \
  -H "Content-Type: application/json" \
  -d '{"email": "john@doe.com", "password": "123456"}' \
  -c cookies.txt

You should get a JSON response containing your access token:

{"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

The -c cookies.txt flag saves the cookies (including our refresh token) to a file named cookies.txt. This simulates how a browser would automatically store and send HTTP-only cookies.

Test 3: Access the protected route with the access token

Copy the accessToken from the previous step and use it in the Authorization header:

curl -X GET http://localhost:8080/private \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN_HERE"

Replace YOUR_ACCESS_TOKEN_HERE with the actual access token you received. You should now get access to the private resource:

{
  "message": "This is a protected route!",
  "user": {
    "userId": "user123",
    "iat": 1640995200, // Example timestamp
    "exp": 1640996100  // Example timestamp
  }
}

Success! You can now access the protected route.

Test 4: Refresh your access token

After your access token expires (or to simulate a refresh), use the refresh token stored in cookies.txt to get a new pair:

curl -X POST http://localhost:8080/auth/refresh \
  -b cookies.txt

The -b cookies.txt flag sends the cookies we saved earlier (which contains the refresh token). You should receive a new access token:

{"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

And behind the scenes, a new refresh token has been set in your cookies.txt file, replacing the old one.


What We’ve Built

Congratulations! You’ve successfully built a complete JWT authentication system with:

What’s nice about this implementation is that it’s stateless (for access token validation) while providing secure session management through refresh tokens. While we used in-memory storage for this demo, remember that in a production environment, you would integrate a persistent database to manage refresh tokens.

Thank you for reading until the end, I hope you found this article helpful and it helps you implement it on your own projects.

Here’s the full code for the project: nicholascostadev/fastify-jwt-auth-example. The code there is a bit more complex because I’ve implemented tests to ensure it’s all working and I improved the type definitions, but the core logic is the same.



Next Post
The Feature Fallacy - It's Time to Stop Building and Start Fixing