bw2kpxc

tl;dr

I wrote a script to help convert exported JSON Bitwarden vaults to KeePassXC compatible CSVs.

Introduction

Let’s say you use Bitwarden as your password manager. It’s convenient, cloud native, has a free tier and is open source. As far as password managers go, this is the one I recommend to most people.

But what if you want to switch to a completely offline password manager, like KeePassXC? Unfortunately it’s not as simple as exporting your vault from Bitwarden, and then importing into a new password database in KeePassXC.

A Tale of Two Formats

Bitwarden supports exporting vaults in JSON and CSV format. But CSV exports have a limitation, per their docs:

Bitwarden .csv files will only handle logins and secure notes. If you need to import or export identities and cards as well, use JSON.

Here’s an example:

{
  "folders": [
    {
      "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "name": "Folder Name"
    },
    ...
  ],
  "items": [
    {
    "passwordHistory": [
        {
          "lastUsedDate": "YYYY-MM-00T00:00:00.000Z",
          "password": "passwordValue"
        }
    ],
    "revisionDate": "YYYY-MM-00T00:00:00.000Z",
    "creationDate": "YYYY-MM-00T00:00:00.000Z",
    "deletedDate": null,    
    "id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
    "organizationId": null,
    "folderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "type": 1,
    "reprompt": 0,
    "name": "My Gmail Login",
    "notes": "This is my gmail login for import.",
    "favorite": false,
    "fields": [
        {
          "name": "custom-field-1",
          "value": "custom-field-value",
          "type": 0
        },
        ...
      ],
      "login": {
        "uris": [
          {
            "match": null,
            "uri": "https://mail.google.com"
          }
        ],
        "username": "myaccount@gmail.com",
        "password": "myaccountpassword",
        "totp": otpauth://totp/my-secret-key
      },
      "collectionIds": null
    },
    ...
  ]
}

KeePassXC does not support import and export of JSON files (though it is in the queue). It supports importing as a CSV, like this:

"Group","Title","Username","Password","URL","Notes","TOTP","Icon","Last Modified","Created"
"Root","My Password","test","password","https://example.com","","","0","2024-02-20T20:45:40Z","2024-02-20T20:45:22Z"

This is pretty straightforward. Below is how we will map the fields:

Bitwarden FieldKeePassXC Field
folderId (mapped via folders dict)Group
nameTitle
login.usernameUsername
login.passwordPassword
login.uris[0].uriURL
notes, fields, cardNotes

A couple things:

  • Notice that the bitwarden fields notes, fields, and card are mapped to the Notes KeePassXC field
  • TOTP fields aren’t handled
  • These fields will require some additional work and are outside the current scope.1

Implementation

At a high level, convert.py script works as follows:

  • Takes an exported Bitwarden JSON file as an argument and reads it using Python’s json.load().
  • Loads folder mappings from the JSON to a Python dictionary with load_folders().
  • Iterates over items, processing each into CSV format using process_item(), leveraging Python’s dict.get() for field extraction.
  • Writes the processed data to a new CSV file using csv.writer()

Here it is:

convert.py

import json
import csv
import sys

def load_folders(folder_data):

    folders = {folder["id"]: folder["name"] for folder in folder_data}
    folders[None] = "No Folder"
    return folders

def process_item(item, folders):

    group = folders.get(item.get("folderId"), "Imported")
    title = item.get("name", "")
    username = (
        item.get("login", {}).get("username", "") if item.get("type") == 1 else ""
    )
    password = (
        item.get("login", {}).get("password", "") if item.get("type") == 1 else ""
    )
    url = (
        item.get("login", {}).get("uris", [{}])[0].get("uri", "")
        if item.get("type") == 1 and item.get("login", {}).get("uris")
        else ""
    )
    ## If the item has additional fields, we add them to the `Notes` field. 
    ## IMPORTANT: If the additional field was of type "hidden", it will be shown in plain text inside the `Notes` field!
    ## TODO: Map to KeePassXC entry's 'Advanced' > 'Additional Attributes' field (with "protect" flag set to "true" for hidden fields)
    notes = item.get("notes", "") or ""
    if item.get("fields"):
        for field in item["fields"]:
            notes += f"\n{field['name']}: {field.get('value', '')}"
    if item.get("type") == 2:
        notes += "\nSecure Note"
    if item.get("type") == 3:
        card = item.get("card", {})
        notes += f"\nCard Number: {card.get('number', '')}\nExpiry: {card.get('expMonth', '')}/{card.get('expYear', '')}"

    return [group, title, username, password, url, notes]

def convert(bw_file):

    csv_path = bw_file.rsplit('.', 1)[0] + '_keepassxc.csv'

    with open(bw_file, "r", encoding="utf-8") as json_file:
        data = json.load(json_file)
        folders = load_folders(data.get("folders", []))
        items = data.get("items", [])

        with open(csv_path, "w", newline="", encoding="utf-8") as csv_file:
            csv_writer = csv.writer(csv_file)
            csv_writer.writerow(
                ["Group", "Title", "Username", "Password", "URL", "Notes"]
            )
            for item in items:
                csv_writer.writerow(process_item(item, folders))

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python json2csv.py bitwarden_exported_json_file.json")
        sys.exit(1)
    bw_file = sys.argv[1]
    convert(bw_file)
    print(f"Conversion complete. KeePassXC CSV file created at {bw_file.rsplit('.', 1)[0] + '_keepassxc.csv'}")

It’s basic with no frills, but solves my problem.

Conclusion

The convert.py script is a practical tool for converting Bitwarden JSON vaults to KeePassXC compatible CSVs. It handles essential fields for a basic password manager migration, but does not cover all fields like TOTP and any Custom Fields defined for Bitwarden entries.

The script is a good starting point for those transitioning from Bitwarden to KeePassXC. Future enhancements could include handling more complex fields and scenarios.

Feel free to contact me or contribute!


  1. Ideally we’d map each Bitwarden Custom Fields to the corresponding KeePassXC entry’s ‘Advanced’ > ‘Additional Attributes’ field. This might work for each Custom Field type Text, Boolean, Hidden, and maybe Linked↩︎