Microsoft Teams Forensics: Purview, Graph and Cache
Acquire Teams chats via Purview eDiscovery, the Microsoft Graph chatMessage API or a Teams (free) export, and know which fields, edits and deletions matter.
Teams is hard to acquire because there is no single place where "the chat" lives. Messages sit in the Teams service. Compliance copies land in Exchange Online mailboxes. Clients keep a partial local cache, and each route gives a different view of the same conversation. This guide covers the three acquisition routes most practitioners use, what the local cache is and is not, and the fields that carry the most evidential weight.
Microsoft renames and reorganizes these products often. Treat the menu paths and licensing notes below as orientation, and check current Microsoft documentation before relying on them in a report.
Route 1: Microsoft Purview eDiscovery
For corporate tenants, Purview eDiscovery is the defensible default. It runs under the tenant's own compliance controls, is auditable, and does not require touching user endpoints.
Background worth knowing: for 1:1 and group chats, Microsoft 365 stores compliance copies of Teams messages in a hidden folder of each participant's Exchange Online mailbox. For channel conversations, the copies go to the team's group mailbox. These are the items eDiscovery searches. Each one has the message class:
IPM.SkypeTeams.Message
Practical consequences:
- Export shape varies with export options. Depending on the eDiscovery experience and settings, Teams content can come out inside PST files, as individual message items, or as collated conversation transcripts. Decide on the format before exporting, and record the settings you used.
- Items look like email but are not email. There are no meaningful
Received:headers, no SPF or DKIM, and the "subject" is often empty or a chat topic. The sender, recipients (chat members at the time) and times come from MAPI properties. - Chat membership matters. Because chat copies are stored per participant mailbox, searching one custodian gives you that person's view. Collect every relevant custodian and deduplicate, rather than assuming one mailbox holds the whole thread.
- Retention and holds decide what exists. If a retention policy deleted content before a hold was placed, eDiscovery cannot bring it back.
Route 2: Microsoft Graph API chatMessage
For targeted collections, or where eDiscovery is not available to you, the Microsoft Graph API exposes Teams messages as chatMessage resources:
GET /chats/{chat-id}/messages
GET /teams/{team-id}/channels/{channel-id}/messages
GET /teams/{team-id}/channels/{channel-id}/messages/{message-id}/replies
Notes from the field:
- Channel messages and their replies are fetched separately. Replies carry a
replyToIdpointing at the root post. - Responses are paged. Each page is a JSON object with a
valuearray, and an@odata.nextLinkif there is more. Save every page exactly as returned, with the request URL and retrieval time, before transforming anything. - Permissions differ between delegated and application access. Tenant-wide export endpoints are protected APIs that Microsoft may require you to request access for, and that may carry licensing or metering requirements. Check the current Graph documentation for the endpoint you use.
A trimmed example of a single message:
{
"id": "1709561100123",
"replyToId": null,
"messageType": "message",
"createdDateTime": "2024-03-04T14:05:00.123Z",
"lastModifiedDateTime": "2024-03-04T14:09:41.500Z",
"lastEditedDateTime": "2024-03-04T14:09:41.500Z",
"deletedDateTime": null,
"chatId": "19:abc123@unq.gbl.spaces",
"from": { "user": { "id": "00000000-0000-0000-0000-000000000001", "displayName": "Alex Doe" } },
"body": { "contentType": "html", "content": "<p>Updated bank details attached</p>" },
"attachments": [],
"reactions": [
{ "reactionType": "like", "createdDateTime": "2024-03-04T14:10:02Z",
"user": { "user": { "id": "00000000-0000-0000-0000-000000000002" } } }
]
}
Route 3: Teams (free) personal data export
Consumer Teams accounts (the free, personal product, not work or school accounts) can request a personal data export through Microsoft's privacy tools. The chat content arrives as a messages.json in the same general shape as a Skype export: a top-level userId, a conversations array, and a MessageList per conversation.
{
"userId": "8:live:.cid.0123456789abcdef",
"conversations": [
{
"id": "19:example@thread.v2",
"displayName": "Project chat",
"MessageList": [
{
"id": "1709561100123",
"originalarrivaltime": "2024-03-04T14:05:00.123Z",
"messagetype": "RichText/Html",
"from": "8:live:.cid.0123456789abcdef",
"displayName": "Alex Doe",
"content": "<p>see you at 3</p>",
"conversationid": "19:example@thread.v2",
"properties": { "edittime": "1709561381000" }
}
]
}
]
}
This export comes from the account holder, so it fits consent-based and civil matters. It reflects what the service still holds for that account at export time.
The local client cache
Teams desktop clients keep a browser-style cache on disk:
Classic Teams (Electron):
%APPDATA%\Microsoft\Teams\IndexedDB\
New Teams (WebView2):
%LOCALAPPDATA%\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\EBWebView\WV2Profile_tfw\IndexedDB\
These folders hold IndexedDB data backed by LevelDB. They can contain fragments of recently viewed conversations, contact records and sometimes content no longer visible in the client. They are partial and structured around what the client needed to render, not a complete record.
MessagingForensics reads them in the browser with its Rust/WebAssembly engine. Drop the whole …indexeddb.leveldb folder (with its .indexeddb.blob sibling if present), or loose .log/.ldb files from a forensic export. The engine:
- replays the
.logwrite-ahead files and reads the.ldbtables, including Snappy-compressed blocks; - keeps every version of every key and labels it live, overwritten, deleted (a later tombstone removed it) or carved;
- carves CRC-valid log records out of file slack or unallocated space, even at unaligned offsets;
- decodes IndexedDB keys and database/object-store names, and deserializes values from V8's format, including Chrome's Snappy-compressed and blob-wrapped values.
Message objects are then recognized by their fields: a content body, a compose or arrival time, a sender display name, and a conversation or client message ID. The client's internal schema is undocumented and changes between versions, so treat that mapping as a lead. Check the raw record in the Artifacts tab (every decoded record is listed there with its file, offset and sequence number) and corroborate against eDiscovery or Graph data for the message record itself.
Fields worth examining
| Field (Graph) | Why it matters |
|---|---|
createdDateTime | UTC time the message was created on the service. Your primary timeline anchor. |
lastEditedDateTime | Set when the content was edited. The Graph resource holds only the current body, so the previous text is not in the message itself. |
lastModifiedDateTime | Changes on any modification, including reactions. Do not read it as "edited". |
deletedDateTime | Populated when the message was deleted. Deleted messages may still appear with this set and the content removed. Verify how your collection route represents them. |
replyToId | Links a channel reply to its root post. |
messageType | Separates user messages from system and event messages, such as members added or calls, which carry context but not user text. |
from.user / from.application | Human sender versus bot or app. System messages may have no user sender. |
reactions | Reaction type plus the reacting user and time, which is useful for showing who read or acknowledged a message. |
attachments | References to files (often SharePoint or OneDrive URLs), cards and quoted messages. File content is not embedded, so collect it separately from SharePoint or OneDrive. |
In the Skype-shaped export, the equivalents are originalarrivaltime, messagetype, properties.edittime and properties.deletetime.
Two cautions. First, display names are not identities. Pivot on the user object ID or MRI (8:orgid:…, 8:live:…) when attributing messages. Second, chats on a Teams call can include event messages that describe the call but not what was said.
Doing this in MessagingForensics
MessagingForensics runs in the browser. Evidence files are SHA-256 hashed on load and never uploaded. For Teams it reads:
- Purview
.msgitems. Outlook.msgfiles whose message class starts withIPM.SkypeTeamsare labeled as Teams. The conversation name comes from the conversation topic property, and the time from the client submit time, falling back to delivery and then creation time. The message class is shown in the detail panel. PST exports must first be converted (for example with readpst). The tool refuses PST and OST directly. See email header forensics for the conversion commands. - Graph
chatMessageJSON. A saved response page ({"value": [...]}) or a bare array of messages. It mapscreatedDateTimeto the timeline, renders HTML bodies as text, and keepslastEditedDateTimeas the edit time. A populateddeletedDateTimebecomes a deleted flag. It also mapsreplyToId,messageType, attachment names and URLs, andreactionTypevalues (the reacting user is not kept). Channel messages are grouped bychannelIdentityteam and channel IDs, and chats bychatId. - Teams (free)
messages.json. Parsed with the Skype-style reader. It is labeled Teams when the top-leveluserIdlooks like a Teams ID or the file path contains "teams". Otherwise it is labeled as a Skype export, so keep "teams" in the folder or file name.properties.edittimeandproperties.deletetimebecome the edit time and deleted flag.
Filter by the deleted flag, switch the timeline between UTC and local time, and export CSV, JSON or an HTML report of the filtered set. Open the analyzer to load an export. Related guides: Slack export forensics, Discord data package forensics, and what is messaging forensics.