errno

The new Teams keeps its local chat cache in WV2Profile_tfw, not Default

· in Windows and WSL · tested on New Microsoft Teams (WebView2) on Windows 11, Chromium IndexedDB/LevelDB, Python

Symptom

I wanted a copy of my own chat history out of the new Teams desktop client. Not somebody else’s messages, not the whole tenant — mine, for archiving, in a form I could grep. No administrator available to help.

Two walls, in this order. The first is the documented API. A device-code flow against a first-party client id, asking for the delegated Chat.Read scope, gets as far as the sign-in page and then stops:

Approval required

The page states that the application requires your administrator’s approval to read user chat messages. That is not a transient error and not a scope typo. User consent is disabled by tenant policy, so the token request never completes, and retrying it — or swapping in a different first-party client id, which is the next thing everybody tries — produces the same page. The only unlock is admin consent.

The second wall is the one worth this post. The new Teams is a WebView2 application, so it keeps a local cache in Chromium’s IndexedDB, and that cache is readable with your own user rights. The directory everybody looks in first is:

%LOCALAPPDATA%\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\EBWebView\Default\

On this client, that path holds nothing useful. It exists, it looks like a Chromium profile, and the chat data is not in it. If you stop there you will conclude the client keeps no local copy and throw away the one approach that actually works.

What it is not: the on-premises mailbox red herring

Before the directory hunt, rule out the wrong explanation for the Graph failure, because it sends you down a completely different road.

If a mailbox is hosted on-premises in a hybrid setup, Graph REST refuses to serve mail and calendar for that user and says so plainly — MailboxNotEnabledForRESTAPI. People who have hit that once tend to generalise it: “Graph can’t see my data because my mailbox is on-prem.” For chat, that is wrong. Teams messages do not live in the mailbox the way mail does; they live in a cloud-side store, and a hybrid on-premises mailbox does not affect access to them.

The distinction matters because the two failures have opposite workarounds. A REST-blocked mailbox is a migration problem: nothing you do at the API layer fixes it, the mailbox has to move. A consent-blocked scope is a policy problem: the data is right there and reachable the moment one administrator clicks approve. Tell them apart by the response you get. MailboxNotEnabledForRESTAPI comes back from the resource after you hold a token; “Approval required” happens before you get one. If you never received a token, the mailbox is irrelevant.

What I measured

With the API route parked, I went looking for the bytes. The MSIX package root is the obvious starting point, and the WebView2 user-data directory under it (EBWebView) is laid out like any Chromium profile tree — one subdirectory per browser profile, each with its own IndexedDB, Local Storage, Cache and so on.

Default was the wrong profile. The data is one directory over, under a profile name that no documentation had prepared me for:

%LOCALAPPDATA%\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\EBWebView\
  WV2Profile_tfw\IndexedDB\https_teams.microsoft.com_0.indexeddb.leveldb\

That is a LevelDB directory with the usual *.ldb tables, a CURRENT file and a manifest. Beside it sits the matching .blob directory, which is where IndexedDB parks large values — in this case attachment payloads, referenced from the records rather than inlined in them.

So the useful measurement was simply: enumerate the profile directories instead of assuming the name. Default is a Chromium convention, not a guarantee; an embedder is free to create its profile under any name it likes, and this one does.

The fix: snapshot with Teams closed, then decode

Two steps, and the first is not optional.

Close Teams and copy the directory. A live LevelDB has an open write-ahead log and unflushed writes in memory. Parsing it in place gives you a torn view at best, and any tool that decides to replay or compact the log is writing into the application’s own database. Quit the client — fully, including the tray icon — and take a snapshot:

# from WSL, or use robocopy /E from PowerShell
BASE="/mnt/c/Users/<you>/AppData/Local/Packages/MSTeams_8wekyb3d8bbwe/LocalCache/Microsoft/MSTeams/EBWebView/WV2Profile_tfw/IndexedDB"
cp -r "$BASE/https_teams.microsoft.com_0.indexeddb.leveldb" ./snap/
cp -r "$BASE/https_teams.microsoft.com_0.indexeddb.blob"    ./snap/

Work only on ./snap/ from here on.

Decode it with an IndexedDB-aware reader. This is the part where a generic LevelDB library is not enough. A plain reader hands you keys and opaque binary blobs, because IndexedDB wraps every record in its own key encoding and then stores the value in Blink’s V8 structured-clone format. You need something that understands both layers. ccl_chromium_indexeddb, from the ccl_chromium_reader package, does:

from ccl_chromium_reader import ccl_chromium_indexeddb

wrapper = ccl_chromium_indexeddb.WrappedIndexDB(
    "snap/https_teams.microsoft.com_0.indexeddb.leveldb",
    "snap/https_teams.microsoft.com_0.indexeddb.blob",
)

for db_id in wrapper.database_ids:
    db = wrapper[db_id]
    for store_name in db.object_store_names:
        store = db[store_name]
        for record in store.iterate_records(errors_to_stdout=True):
            print(store_name, record.user_key, record.value)

record.value comes out as ordinary Python dicts and lists — that is the structured-clone decoding doing the work. From there it is your own filtering: find the object store that holds messages, keep the fields you care about, write JSON. Two things about that loop matter. The errors_to_stdout flag is not decoration: one record the deserialiser cannot handle will otherwise abort the whole iteration with an exception. And a LevelDB reader at this level returns superseded and deleted versions of records alongside the live ones, so the same message can appear more than once in different states. Deduplicate on your own key, and do not assume a record you found is a record the client would still show.

What this gets you, and what it does not

Be honest with yourself about what a client cache is:

This is a best-effort extraction. It is genuinely useful for “what did we agree in that thread” and genuinely unsuitable as an archive of record.

If you need completeness, the routes all go through an administrator, and knowing their names turns a vague request into a concrete ask:

Ask for one of those four by name. “Can you give me my chats” gets nowhere.

What to remember

When a vendor application is a browser in a costume, its data is in the browser’s format. That is good news: the formats are well understood and the tooling already exists, which is why an IndexedDB reader written for forensics work opens a Teams cache without knowing anything about Teams.

The trap is the layer above the format. A profile directory name is an implementation detail. Default is what Chromium picks, WV2Profile_tfw is what this embedder picked, and nothing obliges the two to agree with each other or with any documentation. Enumerate what is on disk before you conclude that a cache is empty or a feature does not exist — I nearly wrote off the whole approach on the strength of one empty directory with a plausible name.

And separate “the API refused me” from “the API cannot do this”. The first is a policy setting and someone can flip it in a minute; the second needs a different plan entirely. Teams has form for this kind of quiet, misleading failure — the same product will happily return HTTP 200 for a webhook that posts nothing. Read the failure carefully enough to tell which one you have.

teams windows forensics indexeddb