Prevent account sharing with Clerk

published: 06/03/2024

6 min read

Scenario

Imagine the following scenario:

When User B logs in using User A’s credentials from a different device, the platform invalidates User A’s active session, logs them out, and allows User B to access the account to prevent account sharing.

The Problem

While Clerk provides a way to invalidate a user session, there aren’t any docs or guides on achieving the said scenario. Thus, I’ve written this guide to illustrate the process.

The Guide

Before we continue

Disclaimer

This walkthrough will not cover how Clerk was set up with TRPC & NextJS 14 as seen in the example repo. It’ll only go through the core logic/implementation of preventing account sharing.

Tech Stack

This guide uses the following tech stack:

  1. Create T3 APP
    • NextJS 14 app directory
    • TRPC 11
  2. Pusher Channels
    • We’ll be using the free tier which allows for 200k messages per day & 100 concurrent connection.
  3. Clerk

While it’s using bleeding edge tech, I believe the concept described in the guide can be used on a traditional client-server architecture as well.

Folder Structure

src
├── app
│   ├── (auth)
│   │   ├── layout.tsx
│   │   └── sign-in
│   │       └── [[...sign-in]]
│   │           └── page.tsx
│   ├── (internal)
│   │   └── app
│   │       ├── dashboard
│   │       │   └── page.tsx
│   │       └── layout.tsx
│   ├── api
│   │   └── pusher
│   │      └── auth
│   │          └── route.ts
│   └── layout.tsx
├── hooks
│   └── use-setup-session-management.ts
├── lib
│   └── pusher
│       ├── client.ts
│       └── server.ts
├── server
│   └── api
│       └── routers
│          └── user.ts
└── utils
    ├── app-provider.tsx
    └── route-paths.ts

Implementation

The full code is right here

Configuring a web socket service on your application

With a pusher account, follow it’s JS SDK walkthrough to retrieve the environment variables from your account.

Now we need to setup a client and server pusher instance as follows:

Client

// src/lib/pusher/client.ts

import PusherClient from "pusher-js";
import { env } from "~/env.js";

const PUSHER_AUTH_ENDPOINT = "/api/pusher/auth";
export const pusherClient = new PusherClient(env.NEXT_PUBLIC_PUSHER_KEY, {
  cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER,
  authEndpoint: PUSHER_AUTH_ENDPOINT,
});

Notes

Server

// src/lib/pusher/server.ts

import PusherServer from "pusher";
import { env } from "~/env.js";

let pusherInstance: PusherServer | null = null;

export const getPusherInstance = () => {
  if (!pusherInstance) {
    pusherInstance = new PusherServer({
      appId: env.PUSHER_APP_ID,
      key: env.NEXT_PUBLIC_PUSHER_KEY,
      secret: env.PUSHER_SECRET,
      cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER,
      useTLS: true,
    });
  }
  return pusherInstance;
};

Authenticating Client Pusher Request on the server

// src/app/api/pusher/auth/route.ts

import { getPusherInstance } from "~/lib/pusher/server";

const pusherServer = getPusherInstance();

export async function POST(req: Request) {
  // see https://pusher.com/docs/channels/server_api/authenticating-users
  const data = await req.text();
  const [socket_id, channel_name] = data
    .split("&")
    .map((str) => str.split("=")[1]);

  // use JWTs here to authenticate users before continuing
  try {
    const auth = pusherServer.authorizeChannel(socket_id!, channel_name!);
    return new Response(JSON.stringify(auth));
  } catch (error) {
    console.error("pusher/auth.handler.catch", { details: error });
  }
}
Establish a client web socket connection

Client: Creating a hook

// src/hooks/use-setup-session-management.ts

import { useUser } from "@clerk/nextjs";
import { pusherClient } from "~/lib/pusher/client";
import { useState } from "react";

let didInit = false;
export const useSubscribeToSessionChannel = () => {
  const { user } = useUser();

  useEffect(() => {
    if (!didInit) {
      const channel = pusherClient
        .subscribe("private-session")
        .bind(
          `evt::revoke-${user?.id}`,
          (data: { type: string; data: string[] }) => {
            if (data.type === "session-revoked") {
	            // handle session removal
            }
          },
        );
      didInit = true;

      return () => {
        channel.unbind();
        didInit = false;
      };
    }
  }, [user, handleSessionRemoval]);
};

Notes:

Client: Mounting it in the protected directory provider

// src/utils/app-provider.tsx

'use client'
import { useSubscribeToSessionChannel } from "~/hooks/use-setup-session-management";
import { type BaseChildrenProps } from "~/types/common";

export const AppProvider = (props: BaseChildrenProps) => {
  const { children } = props

  useSubscribeToSessionChannel();

  return <>
    {children}
  </>
}

Notes:

Query for extra session on the server side

Server

// src/server/api/routers/user.ts

import { clerkClient } from "@clerk/nextjs/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { getPusherInstance } from '~/lib/pusher/server';

const pusherServer = getPusherInstance();

export const userRouter = createTRPCRouter({
  getExcessSessions: protectedProcedure.query(async ({ ctx }) => {
    const { userId, sessionId: currentSessionId } = ctx.auth;

    const { data: activeSessions } = await clerkClient.sessions.getSessionList({
      userId,
      status: "active",
    });

    if (activeSessions.length <= 1) return null;

    const excessSessionsIds = activeSessions
      .filter((session) => session.id !== currentSessionId)
      .map((session) => session.id);

    const revokeSessionsPromises = excessSessionsIds.map((sessionId) =>
      clerkClient.sessions.revokeSession(sessionId),
    );

    try {
      await Promise.all(revokeSessionsPromises).then(async () => {
        await pusherServer
          .trigger("private-session", `evt::revoke-${userId}`, {
            type: "session-revoked",
            data: excessSessionsIds,
          })
      });
    } catch (error) {
      console.error(error);
    } finally {
      return {};
    }
  }),
})

Notes

Client

// src/utils/app-provider.tsx 

'use client'
import { useSubscribeToSessionChannel } from "~/hooks/use-setup-session-management";
import { api } from "~/trpc/react";
import { type BaseChildrenProps } from "~/types/common";

export const AppProvider = (props: BaseChildrenProps) => {
  const { children } = props

  useSubscribeToSessionChannel();
  const excessSessionQuery = api.user.getExcessSessions.useQuery()

  if (excessSessionQuery.isLoading) return <p>Loading...</p>

  return <>
    {children}
  </>
}
Sign user out based on extra sessions

We’re going to handle the information from the callback in the web socket connection

We’d need to define how to handle the session removal when the web socket receives an item:

// src/hooks/use-setup-session-management.ts

const useHandleSignOut = () => {
  const { signOut } = useClerk();
  const router = useRouter();

  return async (currentSessionId: string) => {
    await signOut(() => {
      router.push(`${ROUTE_PATHS.SIGNIN}?forcedRedirect=true`);
    }, {
      sessionId: currentSessionId
    })
  }
};

const useHandleSessionRemoval = () => {
  const { session: currentSession } = useClerk();
  const handleSignOut = useHandleSignOut();

  return async (excessSessionIds: string[]) => {
    try {
      const hasExcess = excessSessionIds.length > 0;
      const isCurrentSessionExcess =
        hasExcess && currentSession && excessSessionIds.includes(currentSession.id);

      if (!isCurrentSessionExcess) return;

      await handleSignOut(currentSession.id);
    } catch (error) {
      console.error('Error removing session:', error);
    }
  };
};

Refer to here for the full code.

Touching up the UI

After the user is signed out, they’ll be redirected to the sign in page. At this point we’d want to notify the user that they’ve been logged out.

Recall this is how we redirected the user

router.push(`${ROUTE_PATHS.SIGNIN}?forcedRedirect=true`);

Notice the query parameter of forcedRedirect=true, this is used to trigger a toast to indicate the user they’ve been logged out on the sign in page.

Here’s how:

// src/app/(auth)/sign-in/[[...sign-in]]/page.tsx

"use client";
import { SignIn } from "@clerk/nextjs";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { ROUTE_PATHS } from "~/utils/route-paths";
import toast from "react-hot-toast";

export default function SignInPage() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const forcedRedirect = searchParams.get("forcedRedirect");

  useEffect(() => {
    if (forcedRedirect) {
      toast.error('Detected additional sessions, kicking you out');
      router.replace(ROUTE_PATHS.SIGNIN, undefined);
    }
  }, [forcedRedirect]);
  return (
    <SignIn />
  );
}

Notes

Conclusion

By the end of this guide, you should have grasp on how to leverage Clerk’s session management to prevent account sharing.

If there’s any questions or remarks, feel free to leave it in the comments!

The full code to this guide is here