Preventing account sharing with Clerk
Tan Shun Yuan
Posted on June 3, 2024
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:
-
Create T3 APP
- NextJS 14 app directory
- TRPC 11
- Pusher Channels
- We'll be using the free tier which allows for 200k messages per day & 100 concurrent connection.
- 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 walkthroughto 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
-
PUSHER_AUTH_ENDPOINT
is an authentication endpoint that will be called on the client side to authenticate the outgoing request to the server.
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:
-
didInit
- Ensures that the hook only run once when mounted. More here
-
pusherClient.subscribe('private-session')
-
private-session
is the channel name
-
-
pusherClient.subscribe('private-session').bind('evt::revoke-${user?.id})
-
evt::revoke-${user?.id}
is the event name to be triggered within the channel.
-
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:
- By putting it in a provider it means that every time the user visits the main application. This connection will be setup ## 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
- Ensure both
channelName
&eventName
for pusher is the same as the one on the client side
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);
}
};
};
- Using
use
as a keyword to prevent typescript from complaining
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
- We detect if there's a searchParam called
forcedRedirect
- If there is we display a toast telling the user that they've been logged out
- We also perform a
router.replace
to remove the query param on the URL to hide the search query.
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
Posted on June 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.