diff --git a/.stylelintrc.js b/.stylelintrc.js
index f028c76cc0..1690f2186f 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -15,6 +15,7 @@ module.exports = {
         "number-leading-zero": null,
         "selector-list-comma-newline-after": null,
         "at-rule-no-unknown": null,
+        "no-descending-specificity": null,
         "scss/at-rule-no-unknown": [true, {
             // https://github.com/vector-im/riot-web/issues/10544
             "ignoreAtRules": ["define-mixin"],
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 29c4d2c84c..5d26185393 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -118,6 +118,7 @@
 @import "./views/messages/_MEmoteBody.scss";
 @import "./views/messages/_MFileBody.scss";
 @import "./views/messages/_MImageBody.scss";
+@import "./views/messages/_MKeyVerificationRequest.scss";
 @import "./views/messages/_MNoticeBody.scss";
 @import "./views/messages/_MStickerBody.scss";
 @import "./views/messages/_MTextBody.scss";
diff --git a/res/css/views/messages/_MKeyVerificationRequest.scss b/res/css/views/messages/_MKeyVerificationRequest.scss
new file mode 100644
index 0000000000..aff44e4109
--- /dev/null
+++ b/res/css/views/messages/_MKeyVerificationRequest.scss
@@ -0,0 +1,93 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_KeyVerification {
+
+    display: grid;
+    grid-template-columns: 24px minmax(0, 1fr) min-content;
+
+    &.mx_KeyVerification_icon::after {
+        grid-column: 1;
+        grid-row: 1 / 3;
+        width: 12px;
+        height: 16px;
+        content: "";
+        mask: url("$(res)/img/e2e/verified.svg");
+        mask-repeat: no-repeat;
+        mask-size: 100%;
+        margin-top: 4px;
+        background-color: $primary-fg-color;
+    }
+
+    &.mx_KeyVerification_icon_verified::after {
+        background-color: $accent-color;
+    }
+
+    .mx_KeyVerification_title, .mx_KeyVerification_subtitle, .mx_KeyVerification_state {
+        overflow-wrap: break-word;
+    }
+
+    .mx_KeyVerification_title {
+        font-weight: 600;
+        font-size: 15px;
+        grid-column: 2;
+        grid-row: 1;
+    }
+
+    .mx_KeyVerification_subtitle {
+        grid-column: 2;
+        grid-row: 2;
+    }
+
+    .mx_KeyVerification_state, .mx_KeyVerification_subtitle {
+        font-size: 12px;
+    }
+
+    .mx_KeyVerification_state, .mx_KeyVerification_buttons {
+        grid-column: 3;
+        grid-row: 1 / 3;
+    }
+
+    .mx_KeyVerification_buttons {
+        align-items: center;
+        display: flex;
+
+        .mx_AccessibleButton_kind_decline {
+            color: $notice-primary-color;
+            background-color: $notice-primary-bg-color;
+        }
+
+        .mx_AccessibleButton_kind_accept {
+            color: $accent-color;
+            background-color: $accent-bg-color;
+        }
+
+        [role=button] {
+            margin: 10px;
+            padding: 7px 15px;
+            border-radius: 5px;
+            height: min-content;
+        }
+    }
+
+    .mx_KeyVerification_state {
+        width: 130px;
+        padding: 10px 20px;
+        margin: auto 0;
+        text-align: center;
+        color: $notice-secondary-color;
+    }
+}
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index db34200b16..98bfa248ff 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -22,6 +22,15 @@ limitations under the License.
     position: relative;
 }
 
+.mx_EventTile_bubble {
+    background-color: $dark-panel-bg-color;
+    padding: 10px;
+    border-radius: 5px;
+    margin: 10px auto;
+    max-width: 75%;
+    box-sizing: border-box;
+}
+
 .mx_EventTile.mx_EventTile_info {
     padding-top: 0px;
 }
@@ -112,6 +121,21 @@ limitations under the License.
     line-height: 22px;
 }
 
+.mx_EventTile_bubbleContainer {
+    display: grid;
+    grid-template-columns: 1fr 100px;
+
+    .mx_EventTile_line {
+        margin-right: 0px;
+        grid-column: 1 / 3;
+        padding: 0;
+    }
+
+    .mx_EventTile_msgOption {
+        grid-column: 2;
+    }
+}
+
 .mx_EventTile_reply {
     margin-right: 10px;
 }
@@ -617,4 +641,5 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
         }
     }
 }
+
 /* stylelint-enable no-descending-specificity */
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index b412261d10..dcd7ce166e 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -12,7 +12,9 @@ $monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emo
 // unified palette
 // try to use these colors when possible
 $accent-color: #03b381;
+$accent-bg-color: rgba(115, 247, 91, 0.08);
 $notice-primary-color: #ff4b55;
+$notice-primary-bg-color: rgba(255, 75, 85, 0.08);
 $notice-secondary-color: #61708b;
 $header-panel-bg-color: #f3f8fd;
 
diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js
index 710a92aa39..0e191cc192 100644
--- a/src/components/views/dialogs/DeviceVerifyDialog.js
+++ b/src/components/views/dialogs/DeviceVerifyDialog.js
@@ -24,6 +24,10 @@ import sdk from '../../../index';
 import * as FormattingUtils from '../../../utils/FormattingUtils';
 import { _t } from '../../../languageHandler';
 import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
+import DMRoomMap from '../../../utils/DMRoomMap';
+import createRoom from "../../../createRoom";
+import dis from "../../../dispatcher";
+import SettingsStore from '../../../settings/SettingsStore';
 
 const MODE_LEGACY = 'legacy';
 const MODE_SAS = 'sas';
@@ -86,25 +90,37 @@ export default class DeviceVerifyDialog extends React.Component {
         this.props.onFinished(confirm);
     }
 
-    _onSasRequestClick = () => {
+    _onSasRequestClick = async () => {
         this.setState({
             phase: PHASE_WAIT_FOR_PARTNER_TO_ACCEPT,
         });
-        this._verifier = MatrixClientPeg.get().beginKeyVerification(
-            verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
-        );
-        this._verifier.on('show_sas', this._onVerifierShowSas);
-        this._verifier.verify().then(() => {
+        const client = MatrixClientPeg.get();
+        const verifyingOwnDevice = this.props.userId === client.getUserId();
+        try {
+            if (!verifyingOwnDevice && SettingsStore.getValue("feature_dm_verification")) {
+                const roomId = await ensureDMExistsAndOpen(this.props.userId);
+                // throws upon cancellation before having started
+                this._verifier = await client.requestVerificationDM(
+                    this.props.userId, roomId, [verificationMethods.SAS],
+                );
+            } else {
+                this._verifier = client.beginKeyVerification(
+                    verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
+                );
+            }
+            this._verifier.on('show_sas', this._onVerifierShowSas);
+            // throws upon cancellation
+            await this._verifier.verify();
             this.setState({phase: PHASE_VERIFIED});
             this._verifier.removeListener('show_sas', this._onVerifierShowSas);
             this._verifier = null;
-        }).catch((e) => {
+        } catch (e) {
             console.log("Verification failed", e);
             this.setState({
                 phase: PHASE_CANCELLED,
             });
             this._verifier = null;
-        });
+        }
     }
 
     _onSasMatchesClick = () => {
@@ -299,3 +315,30 @@ export default class DeviceVerifyDialog extends React.Component {
     }
 }
 
+async function ensureDMExistsAndOpen(userId) {
+    const client = MatrixClientPeg.get();
+    const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
+    const rooms = roomIds.map(id => client.getRoom(id));
+    const suitableDMRooms = rooms.filter(r => {
+        if (r && r.getMyMembership() === "join") {
+            const member = r.getMember(userId);
+            return member && (member.membership === "invite" || member.membership === "join");
+        }
+        return false;
+    });
+    let roomId;
+    if (suitableDMRooms.length) {
+        const room = suitableDMRooms[0];
+        roomId = room.roomId;
+    } else {
+        roomId = await createRoom({dmUserId: userId, spinner: false, andView: false});
+    }
+    // don't use andView and spinner in createRoom, together, they cause this dialog to close and reopen,
+    // we causes us to loose the verifier and restart, and we end up having two verification requests
+    dis.dispatch({
+        action: 'view_room',
+        room_id: roomId,
+        should_peek: false,
+    });
+    return roomId;
+}
diff --git a/src/components/views/messages/MKeyVerificationConclusion.js b/src/components/views/messages/MKeyVerificationConclusion.js
new file mode 100644
index 0000000000..0bd8e2d3d8
--- /dev/null
+++ b/src/components/views/messages/MKeyVerificationConclusion.js
@@ -0,0 +1,132 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import MatrixClientPeg from '../../../MatrixClientPeg';
+import { _t } from '../../../languageHandler';
+import KeyVerificationStateObserver, {getNameForEventRoom, userLabelForEventRoom}
+    from '../../../utils/KeyVerificationStateObserver';
+
+export default class MKeyVerificationConclusion extends React.Component {
+    constructor(props) {
+        super(props);
+        this.keyVerificationState = null;
+        this.state = {
+            done: false,
+            cancelled: false,
+            otherPartyUserId: null,
+            cancelPartyUserId: null,
+        };
+        const rel = this.props.mxEvent.getRelation();
+        if (rel) {
+            const client = MatrixClientPeg.get();
+            const room = client.getRoom(this.props.mxEvent.getRoomId());
+            const requestEvent = room.findEventById(rel.event_id);
+            if (requestEvent) {
+                this._createStateObserver(requestEvent, client);
+                this.state = this._copyState();
+            } else {
+                const findEvent = event => {
+                    if (event.getId() === rel.event_id) {
+                        this._createStateObserver(event, client);
+                        this.setState(this._copyState());
+                        room.removeListener("Room.timeline", findEvent);
+                    }
+                };
+                room.on("Room.timeline", findEvent);
+            }
+        }
+    }
+
+    _createStateObserver(requestEvent, client) {
+        this.keyVerificationState = new KeyVerificationStateObserver(requestEvent, client, () => {
+            this.setState(this._copyState());
+        });
+    }
+
+    _copyState() {
+        const {done, cancelled, otherPartyUserId, cancelPartyUserId} = this.keyVerificationState;
+        return {done, cancelled, otherPartyUserId, cancelPartyUserId};
+    }
+
+    componentDidMount() {
+        if (this.keyVerificationState) {
+            this.keyVerificationState.attach();
+        }
+    }
+
+    componentWillUnmount() {
+        if (this.keyVerificationState) {
+            this.keyVerificationState.detach();
+        }
+    }
+
+    _getName(userId) {
+        const roomId = this.props.mxEvent.getRoomId();
+        const client = MatrixClientPeg.get();
+        const room = client.getRoom(roomId);
+        const member = room.getMember(userId);
+        return member ? member.name : userId;
+    }
+
+    _userLabel(userId) {
+        const name = this._getName(userId);
+        if (name !== userId) {
+            return _t("%(name)s (%(userId)s)", {name, userId});
+        } else {
+            return userId;
+        }
+    }
+
+    render() {
+        const {mxEvent} = this.props;
+        const client = MatrixClientPeg.get();
+        const myUserId = client.getUserId();
+        let title;
+
+        if (this.state.done) {
+            title = _t("You verified %(name)s", {name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
+        } else if (this.state.cancelled) {
+            if (mxEvent.getSender() === myUserId) {
+                title = _t("You cancelled verifying %(name)s",
+                    {name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
+            } else if (mxEvent.getSender() === this.state.otherPartyUserId) {
+                title = _t("%(name)s cancelled verifying",
+                    {name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
+            }
+        }
+
+        if (title) {
+            const subtitle = userLabelForEventRoom(this.state.otherPartyUserId, mxEvent);
+            const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", {
+                mx_KeyVerification_icon_verified: this.state.done,
+            });
+            return (<div className={classes}>
+                <div className="mx_KeyVerification_title">{title}</div>
+                <div className="mx_KeyVerification_subtitle">{subtitle}</div>
+            </div>);
+        }
+
+        return null;
+    }
+}
+
+MKeyVerificationConclusion.propTypes = {
+    /* the MatrixEvent to show */
+    mxEvent: PropTypes.object.isRequired,
+};
diff --git a/src/components/views/messages/MKeyVerificationRequest.js b/src/components/views/messages/MKeyVerificationRequest.js
new file mode 100644
index 0000000000..21d82309ed
--- /dev/null
+++ b/src/components/views/messages/MKeyVerificationRequest.js
@@ -0,0 +1,141 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import MatrixClientPeg from '../../../MatrixClientPeg';
+import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
+import sdk from '../../../index';
+import Modal from "../../../Modal";
+import { _t } from '../../../languageHandler';
+import KeyVerificationStateObserver, {getNameForEventRoom, userLabelForEventRoom}
+    from '../../../utils/KeyVerificationStateObserver';
+
+export default class MKeyVerificationRequest extends React.Component {
+    constructor(props) {
+        super(props);
+        this.keyVerificationState = new KeyVerificationStateObserver(this.props.mxEvent, MatrixClientPeg.get(), () => {
+            this.setState(this._copyState());
+        });
+        this.state = this._copyState();
+    }
+
+    _copyState() {
+        const {accepted, done, cancelled, cancelPartyUserId, otherPartyUserId} = this.keyVerificationState;
+        return {accepted, done, cancelled, cancelPartyUserId, otherPartyUserId};
+    }
+
+    componentDidMount() {
+        this.keyVerificationState.attach();
+    }
+
+    componentWillUnmount() {
+        this.keyVerificationState.detach();
+    }
+
+    _onAcceptClicked = () => {
+        const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
+        // todo: validate event, for example if it has sas in the methods.
+        const verifier = MatrixClientPeg.get().acceptVerificationDM(this.props.mxEvent, verificationMethods.SAS);
+        Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
+            verifier,
+        });
+    };
+
+    _onRejectClicked = () => {
+        // todo: validate event, for example if it has sas in the methods.
+        const verifier = MatrixClientPeg.get().acceptVerificationDM(this.props.mxEvent, verificationMethods.SAS);
+        verifier.cancel("User declined");
+    };
+
+    _acceptedLabel(userId) {
+        const client = MatrixClientPeg.get();
+        const myUserId = client.getUserId();
+        if (userId === myUserId) {
+            return _t("You accepted");
+        } else {
+            return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent)});
+        }
+    }
+
+    _cancelledLabel(userId) {
+        const client = MatrixClientPeg.get();
+        const myUserId = client.getUserId();
+        if (userId === myUserId) {
+            return _t("You cancelled");
+        } else {
+            return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent)});
+        }
+    }
+
+    render() {
+        const {mxEvent} = this.props;
+        const fromUserId = mxEvent.getSender();
+        const content = mxEvent.getContent();
+        const toUserId = content.to;
+        const client = MatrixClientPeg.get();
+        const myUserId = client.getUserId();
+        const isOwn = fromUserId === myUserId;
+
+        let title;
+        let subtitle;
+        let stateNode;
+
+        if (this.state.accepted || this.state.cancelled) {
+            let stateLabel;
+            if (this.state.accepted) {
+                stateLabel = this._acceptedLabel(toUserId);
+            } else if (this.state.cancelled) {
+                stateLabel = this._cancelledLabel(this.state.cancelPartyUserId);
+            }
+            stateNode = (<div className="mx_KeyVerification_state">{stateLabel}</div>);
+        }
+
+        if (toUserId === myUserId) { // request sent to us
+            title = (<div className="mx_KeyVerification_title">{
+                _t("%(name)s wants to verify", {name: getNameForEventRoom(fromUserId, mxEvent)})}</div>);
+            subtitle = (<div className="mx_KeyVerification_subtitle">{
+                userLabelForEventRoom(fromUserId, mxEvent)}</div>);
+            const isResolved = !(this.state.accepted || this.state.cancelled || this.state.done);
+            if (isResolved) {
+                const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
+                stateNode = (<div className="mx_KeyVerification_buttons">
+                    <AccessibleButton kind="decline" onClick={this._onRejectClicked}>{_t("Decline")}</AccessibleButton>
+                    <AccessibleButton kind="accept" onClick={this._onAcceptClicked}>{_t("Accept")}</AccessibleButton>
+                </div>);
+            }
+        } else if (isOwn) { // request sent by us
+            title = (<div className="mx_KeyVerification_title">{
+                _t("You sent a verification request")}</div>);
+            subtitle = (<div className="mx_KeyVerification_subtitle">{
+                userLabelForEventRoom(this.state.otherPartyUserId, mxEvent)}</div>);
+        }
+
+        if (title) {
+            return (<div className="mx_EventTile_bubble mx_KeyVerification mx_KeyVerification_icon">
+                {title}
+                {subtitle}
+                {stateNode}
+            </div>);
+        }
+        return null;
+    }
+}
+
+MKeyVerificationRequest.propTypes = {
+    /* the MatrixEvent to show */
+    mxEvent: PropTypes.object.isRequired,
+};
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 9497324f5a..786a72f5b3 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -33,12 +33,15 @@ import dis from '../../../dispatcher';
 import SettingsStore from "../../../settings/SettingsStore";
 import {EventStatus, MatrixClient} from 'matrix-js-sdk';
 import {formatTime} from "../../../DateUtils";
+import MatrixClientPeg from '../../../MatrixClientPeg';
 
 const ObjectUtils = require('../../../ObjectUtils');
 
 const eventTileTypes = {
     'm.room.message': 'messages.MessageEvent',
     'm.sticker': 'messages.MessageEvent',
+    'm.key.verification.cancel': 'messages.MKeyVerificationConclusion',
+    'm.key.verification.done': 'messages.MKeyVerificationConclusion',
     'm.call.invite': 'messages.TextualEvent',
     'm.call.answer': 'messages.TextualEvent',
     'm.call.hangup': 'messages.TextualEvent',
@@ -68,6 +71,31 @@ const stateEventTileTypes = {
 
 function getHandlerTile(ev) {
     const type = ev.getType();
+
+    // don't show verification requests we're not involved in,
+    // not even when showing hidden events
+    if (type === "m.room.message") {
+        const content = ev.getContent();
+        if (content && content.msgtype === "m.key.verification.request") {
+            const client = MatrixClientPeg.get();
+            const me = client && client.getUserId();
+            if (ev.getSender() !== me && content.to !== me) {
+                return undefined;
+            } else {
+                return "messages.MKeyVerificationRequest";
+            }
+        }
+    }
+    // these events are sent by both parties during verification, but we only want to render one
+    // tile once the verification concludes, so filter out the one from the other party.
+    if (type === "m.key.verification.done") {
+        const client = MatrixClientPeg.get();
+        const me = client && client.getUserId();
+        if (ev.getSender() !== me) {
+            return undefined;
+        }
+    }
+
     return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
 }
 
@@ -527,8 +555,11 @@ module.exports = createReactClass({
         const eventType = this.props.mxEvent.getType();
 
         // Info messages are basically information about commands processed on a room
+        const isBubbleMessage = eventType.startsWith("m.key.verification") ||
+            (eventType === "m.room.message" && msgtype.startsWith("m.key.verification"));
         let isInfoMessage = (
-            eventType !== 'm.room.message' && eventType !== 'm.sticker' && eventType != 'm.room.create'
+            !isBubbleMessage && eventType !== 'm.room.message' &&
+            eventType !== 'm.sticker' && eventType != 'm.room.create'
         );
 
         let tileHandler = getHandlerTile(this.props.mxEvent);
@@ -561,6 +592,7 @@ module.exports = createReactClass({
 
         const isEditing = !!this.props.editState;
         const classes = classNames({
+            mx_EventTile_bubbleContainer: isBubbleMessage,
             mx_EventTile: true,
             mx_EventTile_isEditing: isEditing,
             mx_EventTile_info: isInfoMessage,
@@ -596,7 +628,7 @@ module.exports = createReactClass({
         if (this.props.tileShape === "notif") {
             avatarSize = 24;
             needsSenderProfile = true;
-        } else if (tileHandler === 'messages.RoomCreate') {
+        } else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) {
             avatarSize = 0;
             needsSenderProfile = false;
         } else if (isInfoMessage) {
@@ -794,7 +826,7 @@ module.exports = createReactClass({
                             { readAvatars }
                         </div>
                         { sender }
-                        <div className="mx_EventTile_line">
+                        <div className={classNames("mx_EventTile_line", {mx_EventTile_bubbleLine: isBubbleMessage})}>
                             <a
                                 href={permalink}
                                 onClick={this.onPermalinkClicked}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5af7e26b79..5f6e327944 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -291,6 +291,7 @@
     "%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
     "%(items)s and %(count)s others|one": "%(items)s and one other",
     "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
+    "%(name)s (%(userId)s)": "%(name)s (%(userId)s)",
     "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions",
     "Not a valid Riot keyfile": "Not a valid Riot keyfile",
     "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
@@ -339,6 +340,7 @@
     "Render simple counters in room header": "Render simple counters in room header",
     "Multiple integration managers": "Multiple integration managers",
     "Use the new, consistent UserInfo panel for Room Members and Group Members": "Use the new, consistent UserInfo panel for Room Members and Group Members",
+    "Send verification requests in direct message": "Send verification requests in direct message",
     "Use the new, faster, composer for writing messages": "Use the new, faster, composer for writing messages",
     "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
     "Use compact timeline layout": "Use compact timeline layout",
@@ -1064,6 +1066,15 @@
     "Invalid file%(extra)s": "Invalid file%(extra)s",
     "Error decrypting image": "Error decrypting image",
     "Show image": "Show image",
+    "You verified %(name)s": "You verified %(name)s",
+    "You cancelled verifying %(name)s": "You cancelled verifying %(name)s",
+    "%(name)s cancelled verifying": "%(name)s cancelled verifying",
+    "You accepted": "You accepted",
+    "%(name)s accepted": "%(name)s accepted",
+    "You cancelled": "You cancelled",
+    "%(name)s cancelled": "%(name)s cancelled",
+    "%(name)s wants to verify": "%(name)s wants to verify",
+    "You sent a verification request": "You sent a verification request",
     "Error decrypting video": "Error decrypting video",
     "Show all": "Show all",
     "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index 2220435cb9..b169a0f29c 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -126,6 +126,12 @@ export const SETTINGS = {
         supportedLevels: LEVELS_FEATURE,
         default: false,
     },
+    "feature_dm_verification": {
+        isFeature: true,
+        displayName: _td("Send verification requests in direct message"),
+        supportedLevels: LEVELS_FEATURE,
+        default: false,
+    },
     "useCiderComposer": {
         displayName: _td("Use the new, faster, composer for writing messages"),
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
diff --git a/src/utils/KeyVerificationStateObserver.js b/src/utils/KeyVerificationStateObserver.js
new file mode 100644
index 0000000000..7de50ec4bf
--- /dev/null
+++ b/src/utils/KeyVerificationStateObserver.js
@@ -0,0 +1,170 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import MatrixClientPeg from '../MatrixClientPeg';
+import { _t } from '../languageHandler';
+
+const SUB_EVENT_TYPES_OF_INTEREST = ["start", "cancel", "done"];
+
+export default class KeyVerificationStateObserver {
+    constructor(requestEvent, client, updateCallback) {
+        this._requestEvent = requestEvent;
+        this._client = client;
+        this._updateCallback = updateCallback;
+        this.accepted = false;
+        this.done = false;
+        this.cancelled = false;
+        this._updateVerificationState();
+    }
+
+    attach() {
+        this._requestEvent.on("Event.relationsCreated", this._onRelationsCreated);
+        for (const phaseName of SUB_EVENT_TYPES_OF_INTEREST) {
+            this._tryListenOnRelationsForType(`m.key.verification.${phaseName}`);
+        }
+    }
+
+    detach() {
+        const roomId = this._requestEvent.getRoomId();
+        const room = this._client.getRoom(roomId);
+
+        for (const phaseName of SUB_EVENT_TYPES_OF_INTEREST) {
+            const relations = room.getUnfilteredTimelineSet()
+                .getRelationsForEvent(this._requestEvent.getId(), "m.reference", `m.key.verification.${phaseName}`);
+            if (relations) {
+                relations.removeListener("Relations.add", this._onRelationsUpdated);
+                relations.removeListener("Relations.remove", this._onRelationsUpdated);
+                relations.removeListener("Relations.redaction", this._onRelationsUpdated);
+            }
+        }
+        this._requestEvent.removeListener("Event.relationsCreated", this._onRelationsCreated);
+    }
+
+    _onRelationsCreated = (relationType, eventType) => {
+        if (relationType !== "m.reference") {
+            return;
+        }
+        if (
+            eventType !== "m.key.verification.start" &&
+            eventType !== "m.key.verification.cancel" &&
+            eventType !== "m.key.verification.done"
+        ) {
+            return;
+        }
+        this._tryListenOnRelationsForType(eventType);
+        this._updateVerificationState();
+        this._updateCallback();
+    };
+
+    _tryListenOnRelationsForType(eventType) {
+        const roomId = this._requestEvent.getRoomId();
+        const room = this._client.getRoom(roomId);
+        const relations = room.getUnfilteredTimelineSet()
+            .getRelationsForEvent(this._requestEvent.getId(), "m.reference", eventType);
+        if (relations) {
+            relations.on("Relations.add", this._onRelationsUpdated);
+            relations.on("Relations.remove", this._onRelationsUpdated);
+            relations.on("Relations.redaction", this._onRelationsUpdated);
+        }
+    }
+
+    _onRelationsUpdated = (event) => {
+        this._updateVerificationState();
+        this._updateCallback();
+    };
+
+    _updateVerificationState() {
+        const roomId = this._requestEvent.getRoomId();
+        const room = this._client.getRoom(roomId);
+        const timelineSet = room.getUnfilteredTimelineSet();
+        const fromUserId = this._requestEvent.getSender();
+        const content = this._requestEvent.getContent();
+        const toUserId = content.to;
+
+        this.cancelled = false;
+        this.done = false;
+        this.accepted = false;
+        this.otherPartyUserId = null;
+        this.cancelPartyUserId = null;
+
+        const startRelations = timelineSet.getRelationsForEvent(
+            this._requestEvent.getId(), "m.reference", "m.key.verification.start");
+        if (startRelations) {
+            for (const startEvent of startRelations.getRelations()) {
+                if (startEvent.getSender() === toUserId) {
+                    this.accepted = true;
+                }
+            }
+        }
+
+        const doneRelations = timelineSet.getRelationsForEvent(
+            this._requestEvent.getId(), "m.reference", "m.key.verification.done");
+        if (doneRelations) {
+            let senderDone = false;
+            let receiverDone = false;
+            for (const doneEvent of doneRelations.getRelations()) {
+                if (doneEvent.getSender() === toUserId) {
+                    receiverDone = true;
+                } else if (doneEvent.getSender() === fromUserId) {
+                    senderDone = true;
+                }
+            }
+            if (senderDone && receiverDone) {
+                this.done = true;
+            }
+        }
+
+        if (!this.done) {
+            const cancelRelations = timelineSet.getRelationsForEvent(
+                this._requestEvent.getId(), "m.reference", "m.key.verification.cancel");
+
+            if (cancelRelations) {
+                let earliestCancelEvent;
+                for (const cancelEvent of cancelRelations.getRelations()) {
+                    // only accept cancellation from the users involved
+                    if (cancelEvent.getSender() === toUserId || cancelEvent.getSender() === fromUserId) {
+                        this.cancelled = true;
+                        if (!earliestCancelEvent || cancelEvent.getTs() < earliestCancelEvent.getTs()) {
+                            earliestCancelEvent = cancelEvent;
+                        }
+                    }
+                }
+                if (earliestCancelEvent) {
+                    this.cancelPartyUserId = earliestCancelEvent.getSender();
+                }
+            }
+        }
+
+        this.otherPartyUserId = fromUserId === this._client.getUserId() ? toUserId : fromUserId;
+    }
+}
+
+export function getNameForEventRoom(userId, mxEvent) {
+    const roomId = mxEvent.getRoomId();
+    const client = MatrixClientPeg.get();
+    const room = client.getRoom(roomId);
+    const member = room.getMember(userId);
+    return member ? member.name : userId;
+}
+
+export function userLabelForEventRoom(userId, mxEvent) {
+    const name = getNameForEventRoom(userId, mxEvent);
+    if (name !== userId) {
+        return _t("%(name)s (%(userId)s)", {name, userId});
+    } else {
+        return userId;
+    }
+}