diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx
index 6680345bbc..f3b7988469 100644
--- a/src/components/views/messages/CallEvent.tsx
+++ b/src/components/views/messages/CallEvent.tsx
@@ -20,7 +20,13 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { Call, ConnectionState } from "../../../models/Call";
 import { _t } from "../../../languageHandler";
-import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
+import {
+    useCall,
+    useConnectionState,
+    useJoinCallButtonDisabled,
+    useJoinCallButtonTooltip,
+    useParticipants,
+} from "../../../hooks/useCall";
 import defaultDispatcher from "../../../dispatcher/dispatcher";
 import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
 import { Action } from "../../../dispatcher/actions";
@@ -106,7 +112,8 @@ interface ActiveLoadedCallEventProps {
 const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
     const connectionState = useConnectionState(call);
     const participants = useParticipants(call);
-    const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
+    const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
+    const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
 
     const connect = useCallback((ev: ButtonEvent) => {
         ev.preventDefault();
@@ -138,8 +145,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
         participants={participants}
         buttonText={buttonText}
         buttonKind={buttonKind}
-        buttonDisabled={Boolean(joinCallButtonDisabledTooltip)}
-        buttonTooltip={joinCallButtonDisabledTooltip}
+        buttonDisabled={joinCallButtonDisabled}
+        buttonTooltip={joinCallButtonTooltip}
         onButtonClick={onButtonClick}
     />;
 });
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index 65439e56c9..2a795e4bf1 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -22,7 +22,13 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils";
 import type { Room } from "matrix-js-sdk/src/models/room";
 import type { ConnectionState } from "../../../models/Call";
 import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call";
-import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall";
+import {
+    useCall,
+    useConnectionState,
+    useJoinCallButtonDisabled,
+    useJoinCallButtonTooltip,
+    useParticipants,
+} from "../../../hooks/useCall";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import AppTile from "../elements/AppTile";
 import { _t } from "../../../languageHandler";
@@ -110,11 +116,12 @@ const MAX_FACES = 8;
 interface LobbyProps {
     room: Room;
     connect: () => Promise<void>;
-    joinCallButtonDisabledTooltip?: string;
+    joinCallButtonTooltip?: string;
+    joinCallButtonDisabled?: boolean;
     children?: ReactNode;
 }
 
-export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
+export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => {
     const [connecting, setConnecting] = useState(false);
     const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
     const videoRef = useRef<HTMLVideoElement>(null);
@@ -237,11 +244,11 @@ export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, con
         <AccessibleTooltipButton
             className="mx_CallView_connectButton"
             kind="primary"
-            disabled={connecting || Boolean(joinCallButtonDisabledTooltip)}
+            disabled={connecting || joinCallButtonDisabled}
             onClick={onConnectClick}
             title={_t("Join")}
             label={_t("Join")}
-            tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
+            tooltip={connecting ? _t("Connecting") : joinCallButtonTooltip}
         />
     </div>;
 };
@@ -323,7 +330,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
     const cli = useContext(MatrixClientContext);
     const connected = isConnected(useConnectionState(call));
     const participants = useParticipants(call);
-    const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
+    const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
+    const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
 
     const connect = useCallback(async () => {
         // Disconnect from any other active calls first, since we don't yet support holding
@@ -350,7 +358,8 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
         lobby = <Lobby
             room={room}
             connect={connect}
-            joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip}
+            joinCallButtonTooltip={joinCallButtonTooltip}
+            joinCallButtonDisabled={joinCallButtonDisabled}
         >
             { facePile }
         </Lobby>;
diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts
index 178514f262..cf9bbee0d0 100644
--- a/src/hooks/useCall.ts
+++ b/src/hooks/useCall.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { useState, useCallback } from "react";
+import { useState, useCallback, useMemo } from "react";
 
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { Call, ConnectionState, ElementCall, Layout } from "../models/Call";
@@ -24,6 +24,7 @@ import { CallStore, CallStoreEvent } from "../stores/CallStore";
 import { useEventEmitter } from "./useEventEmitter";
 import SdkConfig, { DEFAULTS } from "../SdkConfig";
 import { _t } from "../languageHandler";
+import { MatrixClientPeg } from "../MatrixClientPeg";
 
 export const useCall = (roomId: string): Call | null => {
     const [call, setCall] = useState(() => CallStore.instance.getCall(roomId));
@@ -56,15 +57,33 @@ export const useFull = (call: Call): boolean => {
     );
 };
 
-export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => {
+export const useIsAlreadyParticipant = (call: Call): boolean => {
+    const client = MatrixClientPeg.get();
+    const participants = useParticipants(call);
+
+    return useMemo(() => {
+        return participants.has(client.getRoom(call.roomId).getMember(client.getUserId()));
+    }, [participants, client, call]);
+};
+
+export const useJoinCallButtonTooltip = (call: Call): string | null => {
     const isFull = useFull(call);
     const state = useConnectionState(call);
+    const isAlreadyParticipant = useIsAlreadyParticipant(call);
 
     if (state === ConnectionState.Connecting) return _t("Connecting");
     if (isFull) return _t("Sorry — this call is currently full");
+    if (isAlreadyParticipant) return _t("You have already joined this call from another device");
     return null;
 };
 
+export const useJoinCallButtonDisabled = (call: Call): boolean => {
+    const isFull = useFull(call);
+    const state = useConnectionState(call);
+
+    return isFull || state === ConnectionState.Connecting;
+};
+
 export const useLayout = (call: ElementCall): Layout =>
     useTypedEventEmitterState(
         call,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index d4ba636e36..1b05ce98f8 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1023,6 +1023,7 @@
     "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
     "Connecting": "Connecting",
     "Sorry — this call is currently full": "Sorry — this call is currently full",
+    "You have already joined this call from another device": "You have already joined this call from another device",
     "Create account": "Create account",
     "You made it!": "You made it!",
     "Find and invite your friends": "Find and invite your friends",
diff --git a/src/models/Call.ts b/src/models/Call.ts
index 455b08e6a5..6a50fe90d8 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -17,7 +17,7 @@ limitations under the License.
 import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
 import { logger } from "matrix-js-sdk/src/logger";
 import { randomString } from "matrix-js-sdk/src/randomstring";
-import { MatrixClient } from "matrix-js-sdk/src/client";
+import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
 import { RoomEvent } from "matrix-js-sdk/src/models/room";
 import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
 import { CallType } from "matrix-js-sdk/src/webrtc/call";
@@ -606,8 +606,11 @@ export interface ElementCallMemberContent {
 export class ElementCall extends Call {
     public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
     public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
+    public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
     public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
 
+    private kickedOutByAnotherDevice = false;
+    private connectionTime: number | null = null;
     private participantsExpirationTimer: number | null = null;
     private terminationTimer: number | null = null;
 
@@ -785,6 +788,16 @@ export class ElementCall extends Call {
         audioInput: MediaDeviceInfo | null,
         videoInput: MediaDeviceInfo | null,
     ): Promise<void> {
+        this.kickedOutByAnotherDevice = false;
+        this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+
+        this.connectionTime = Date.now();
+        await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
+            [this.client.getUserId()]: {
+                "*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
+            },
+        });
+
         try {
             await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
                 audioInput: audioInput?.label ?? null,
@@ -808,6 +821,7 @@ export class ElementCall extends Call {
     }
 
     public setDisconnected() {
+        this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
         this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
         this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
         this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
@@ -845,8 +859,13 @@ export class ElementCall extends Call {
     }
 
     private get mayTerminate(): boolean {
-        return this.groupCall.getContent()["m.intent"] !== "m.room"
-            && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
+        if (this.kickedOutByAnotherDevice) return false;
+        if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
+        if (
+            !this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
+        ) return false;
+
+        return true;
     }
 
     private async terminate(): Promise<void> {
@@ -868,6 +887,17 @@ export class ElementCall extends Call {
         if ("m.terminated" in newGroupCall.getContent()) this.destroy();
     };
 
+    private onToDeviceEvent = (event: MatrixEvent): void => {
+        const content = event.getContent();
+        if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
+        if (event.getSender() !== this.client.getUserId()) return;
+        if (content.device_id === this.client.getDeviceId()) return;
+        if (content.timestamp <= this.connectionTime) return;
+
+        this.kickedOutByAnotherDevice = true;
+        this.disconnect();
+    };
+
     private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
         if (
             (state === ConnectionState.Connected && !isConnected(prevState))
diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx
index c8eff9dc82..c5e363089b 100644
--- a/src/toasts/IncomingCallToast.tsx
+++ b/src/toasts/IncomingCallToast.tsx
@@ -30,7 +30,7 @@ import {
     LiveContentSummaryWithCall,
     LiveContentType,
 } from "../components/views/rooms/LiveContentSummary";
-import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
+import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall";
 import { useRoomState } from "../hooks/useRoomState";
 import { ButtonEvent } from "../components/views/elements/AccessibleButton";
 import { useDispatcher } from "../hooks/useDispatcher";
@@ -45,12 +45,13 @@ interface JoinCallButtonWithCallProps {
 }
 
 function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) {
-    const tooltip = useJoinCallButtonDisabledTooltip(call);
+    const tooltip = useJoinCallButtonTooltip(call);
+    const disabled = useJoinCallButtonDisabled(call);
 
     return <AccessibleTooltipButton
         className="mx_IncomingCallToast_joinButton"
         onClick={onClick}
-        disabled={Boolean(tooltip)}
+        disabled={disabled}
         tooltip={tooltip}
         kind="primary"
     >
diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts
index d8a455d0f3..df57472638 100644
--- a/test/models/Call-test.ts
+++ b/test/models/Call-test.ts
@@ -18,10 +18,11 @@ import EventEmitter from "events";
 import { mocked } from "jest-mock";
 import { waitFor } from "@testing-library/react";
 import { RoomType } from "matrix-js-sdk/src/@types/event";
-import { PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
 import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
 import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
 import { Widget } from "matrix-widget-api";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 
 import type { Mocked } from "jest-mock";
 import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
@@ -85,6 +86,7 @@ const setUpClientRoomAndStores = (): {
     client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
     client.getRooms.mockReturnValue([room]);
     client.getUserId.mockReturnValue(alice.userId);
+    client.getDeviceId.mockReturnValue("alices_device");
     client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
     client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
         if (roomId !== room.roomId) throw new Error("Unknown room");
@@ -814,6 +816,77 @@ describe("ElementCall", () => {
             call.off(CallEvent.Destroy, onDestroy);
         });
 
+        describe("being kicked out by another device", () => {
+            const onDestroy = jest.fn();
+
+            beforeEach(async () => {
+                await call.connect();
+                call.on(CallEvent.Destroy, onDestroy);
+
+                jest.advanceTimersByTime(100);
+                jest.clearAllMocks();
+            });
+
+            afterEach(() => {
+                call.off(CallEvent.Destroy, onDestroy);
+            });
+
+            it("does not terminate the call if we are the last", async () => {
+                client.emit(ClientEvent.ToDeviceEvent, {
+                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
+                    getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
+                    getSender: () => (client.getUserId()),
+                } as MatrixEvent);
+
+                expect(client.sendStateEvent).not.toHaveBeenCalled();
+                expect(
+                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
+                ).toBeTruthy();
+            });
+
+            it("ignores messages from our device", async () => {
+                client.emit(ClientEvent.ToDeviceEvent, {
+                    getSender: () => (client.getUserId()),
+                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
+                    getContent: () => ({ device_id: client.getDeviceId(), timestamp: Date.now() }),
+                } as MatrixEvent);
+
+                expect(client.sendStateEvent).not.toHaveBeenCalled();
+                expect(
+                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
+                ).toBeFalsy();
+                expect(onDestroy).not.toHaveBeenCalled();
+            });
+
+            it("ignores messages from other users", async () => {
+                client.emit(ClientEvent.ToDeviceEvent, {
+                    getSender: () => (bob.userId),
+                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
+                    getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
+                } as MatrixEvent);
+
+                expect(client.sendStateEvent).not.toHaveBeenCalled();
+                expect(
+                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
+                ).toBeFalsy();
+                expect(onDestroy).not.toHaveBeenCalled();
+            });
+
+            it("ignores messages from the past", async () => {
+                client.emit(ClientEvent.ToDeviceEvent, {
+                    getSender: () => (client.getUserId()),
+                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
+                    getContent: () => ({ device_id: "random_device_id", timestamp: 0 }),
+                } as MatrixEvent);
+
+                expect(client.sendStateEvent).not.toHaveBeenCalled();
+                expect(
+                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
+                ).toBeFalsy();
+                expect(onDestroy).not.toHaveBeenCalled();
+            });
+        });
+
         it("ends the call after a random delay if the last participant leaves without ending it", async () => {
             // Bob connects
             await client.sendStateEvent(