DEV Community

Cover image for What Happens After You Log In? Understanding Sessions, Tokens & Authentication
Ken
Ken

Posted on

What Happens After You Log In? Understanding Sessions, Tokens & Authentication

My Github with the full code

GitHub logo norune541 / secure-session-auth

A clean and minimal initial setup for an Express, TypeScript, and PostgreSQL backend project featuring advanced session management, secure authentication flows, and token rotation.

Session management

A clean and minimal initial setup for an Express, TypeScript, and PostgreSQL backend project featuring advanced session management, secure authentication flows, and token rotation.

Tech Stack

  • Runtime: Node.js
  • Framework: Express.js
  • Language: TypeScript
  • Database & ORM: PostgreSQL with Prisma
  • Validation: Zod

Getting Started

  1. Clone the repository

  2. Configure your environment variables by creating .env (for local development) and .env.docker (for containerized execution) files based on .env.example.

  3. Start the infrastructure and application via Docker:

docker compose --env-file .env.docker up --build
Enter fullscreen mode Exit fullscreen mode
  1. Access the services:

Backend API: Available on the port specified in your configuration (3000 by default)

Prisma Studio: Available locally at http://localhost:5555 for database inspection and management.




What is a session?

HTTP is stateless. Each request is independent, and the server doesn't inherently know anything about previous requests. But authentication requires state. After logging in, a user expects to access protected resources without sending their credentials with every request.

So how does the server recognize an authenticated user across multiple requests? This is where sessions come in.

A session represents an authenticated interaction between a client and a server. It allows the server to associate subsequent requests with a specific user.


Before diving in, let's quickly align on terminology. Authentication verifies who the user is, while authorization determines what they can access. A session represents an authenticated login, while an access token is used to access protected resources. A refresh token is used to obtain a new access token, and token rotation replaces the refresh token after use. Revocation allows us to invalidate a session before its tokens expire.


Now that we have the terminology in place, let's look at the lifecycle of an authenticated session.

A session doesn't simply start with a successful login and end with a logout. Throughout its lifetime, it can be created, used to authenticate requests, updated when its state changes, refreshed through token rotation, and eventually revoked or allowed to expire.

In this project, the session lifecycle is built around three main operations:

  • Authenticate: verifies the user's credentials, creates a session, and issues the initial tokens.
  • Rotate: replaces the refresh token hash and issue a new access token.
  • Revoke: invalidates the session and prevent further authentication.

In addition to these lifecycle operations, the API provides a way to retrieve all active sessions belonging to the current user, excluding expired or revoked sessions.


These operations are closely connected. Authentication establishes the session, updates maintain its state, rotation keeps long-lived sessions secure, and revocation provides a way to terminate them before they naturally expire.

The next sections will walk through each stage of this lifecycle using real code from the project, explaining how each step works and why it is implemented this way.

Login flow

When a user authenticates, the server first verifies their credentials. It then generates a short-lived access token (JWT) and a long-lived opaque refresh token, creates a new session with the refresh token hash, and returns both tokens to the client.

The flow above is implemented in the authentication service. The controller handles the HTTP request, while the service contains the authentication logic. Let's walk through it step by step.

1. Find the user

const user = await userService.findUserByEmail(userDto.email);
  if (!user) {
    throw new ApiError("Invalid credentials", 401);
  }
Enter fullscreen mode Exit fullscreen mode

2. Verify the password

const ok = await bcrypt.compare(userDto.password, user.password);
  if (!ok) {
    throw new ApiError("Invalid credentials", 401);
  }
Enter fullscreen mode Exit fullscreen mode

3. Generate a pair of tokens

const accessToken = tokenService.signAccess(user);
const { hash, refreshToken } = tokenService.signRefresh();
Enter fullscreen mode Exit fullscreen mode

signRefresh() generates both the refresh token and its hash. The raw token is kept in memory and returned to the client later, while only its hash is stored in the database.

4. Create the session with metadata (such as IP and user-agent)

await sessionService.create(user.id, metaDto, hash);
Enter fullscreen mode Exit fullscreen mode

5. Return the tokens to the controller

return {
    accessToken,
    refreshToken,
  };
Enter fullscreen mode Exit fullscreen mode

6. Send a token pair to the client

The refresh token is stored in an HttpOnly cookie, preventing client-side JavaScript from accessing it directly. This helps protect the long-lived refresh token from being stolen through an XSS vulnerability.

res.cookie("refreshToken", refreshToken, {
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  sameSite: "strict",
  httpOnly: true,
  secure: env.NODE_ENV === "production",
  path: "/",
});

return res.status(200).json({ accessToken });
Enter fullscreen mode Exit fullscreen mode

Refresh token rotation flow

The access token is intentionally short-lived, so eventually it expires. When the client receives a response indicating that the access token has expired, it sends a request to /sessions/refresh with the refresh token.

The server then validates the refresh token and, if it is still valid, issues a new access token and refresh token pair. The old refresh token is replaced, and its hash in the session is updated accordingly.

1. Validate the refresh token provided by the client.

At this step, we take the raw refresh token provided by the client and hash it using the same algorithm used when the session was created. We then query the database for a session with a matching hash that is still valid, meaning it has not expired or been revoked.

const sessionContext = await sessionService.validateRefreshToken(inputToken);
  if (!sessionContext) {
    throw new ApiError("Invalid or expired refresh token", 401);
  }
Enter fullscreen mode Exit fullscreen mode

In validateRefreshToken():

const inputTokenHash = crypto
    .createHash("sha256")
    .update(inputToken)
    .digest("hex");

where: {
      refreshToken: inputTokenHash,
      revoked: false,
      expiresAt: { gt: new Date() },
    },
Enter fullscreen mode Exit fullscreen mode

2. Issue a new token pair

Once the refresh token has been validated, sessionService.validateRefreshToken() returns the user and the session ID. The user data is used to issue a new access token, while the session ID is kept so we can update the existing session with the new refresh token hash.

const { user, id } = sessionContext;

const accessToken = tokenService.signAccess(user);
const { refreshToken, hash } = tokenService.signRefresh();
Enter fullscreen mode Exit fullscreen mode

3. Update the current session's hash

The existing session is updated rather than creating a new database record. Only the stored refresh token hash is replaced with the hash of the newly issued refresh token. The session's expiration time remains unchanged.

This means that token rotation does not extend the lifetime of the session. Once the original session expires, the user will have to authenticate again, even if the refresh token has been rotated successfully multiple times.

await sessionService.update(id, hash);
Enter fullscreen mode Exit fullscreen mode

4. Return a pair of tokens to the controller

return {
    accessToken,
    refreshToken,
  };
Enter fullscreen mode Exit fullscreen mode

5. Send a token pair to the client

The controller handles the response in the same way as during login: the new refresh token is set in the existing HttpOnly cookie, while the new access token is returned in the response body. The cookie configuration remains unchanged, so there is no need to duplicate it here.


Revoke refresh token flow

In the refresh token revocation flow, the server receives the raw token, hashes it, and uses the hash to mark the matching session as revoked. Since the operation only sets revoked to true, repeating the request leaves the session in the same state, making the operation idempotent.

1. Mark the current session as revoked

export const revoke = async (token: string) => {
  const hash = crypto.createHash("sha256").update(token).digest("hex");
  await prisma.session.updateMany({
    where: {
      refreshToken: hash,
    },
    data: {
      revoked: true,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

2. After revoking the session, the controller clears the refresh token cookie and returns 204 No Content to the client.

  res.clearCookie("refreshToken", {
    path: "/",
    secure: env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "strict",
  });
  return res.sendStatus(204);
Enter fullscreen mode Exit fullscreen mode

List current user's sessions

1. Authenticate the request

The endpoint is protected by JWT authentication. The middleware extracts the access token from the Authorization header, verifies it, and attaches the authenticated user's ID to the request.

2. Retrieve the user's active sessions

The session service retrieves all non-expired and non-revoked sessions belonging to the authenticated user, along with basic session metadata.

export const getUserSessions = async (userId: string) => {
  return await prisma.session.findMany({
    where: {
      userId: userId,
      revoked: false,
      expiresAt: { gt: new Date() },
    },
    select: {
      id: true,
      ip: true,
      userAgent: true,
      createdAt: true,
      expiresAt: true,
      revoked: true,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

3. Return current sessions to the client

export const getAllUserSessions = async (req: Request, res: Response) => {
  const sessions = await sessionsService.getUserSessions(req.user.id);

  return res.status(200).json(sessions);
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

A secure session system is about managing the entire token lifecycle: authentication, rotation, and revocation. By storing only refresh token hashes and keeping session expiration independent from token rotation, we can limit the impact of compromised credentials and maintain control over active sessions.

The complete implementation is available in my open-source GitHub repository:

GitHub: https://github.com/norune541/secure-session-auth

Top comments (0)