Skip to main content

Working with v5 encrypted metadata

Since the v5 format, the descriptive fields of a resource are no longer stored in clear. The name, the username and the URIs travel inside a single encrypted payload, which changes both what the API returns and what you have to send. This guide covers that contract, and the decryption paths behind it.

The rest of the resource is unchanged: permissions, folders, favourites, tags and secrets behave exactly as they do in viewing a resource and creating a resource.

What changes in the payload

A v5 resource carries three fields that a v4 resource does not:

  • metadata, an armoured OpenPGP message holding the descriptive fields. It sits at the top level of the resource, next to id and resource_type_id.
  • metadata_key_type, which says which key encrypted it. Two values exist: user_key and shared_key.
  • metadata_key_id, the identifier of that key.

And it loses three: name, username and uri are not part of the response at all. Not null, absent.

Here is the body of a GET on a v5 resource, with the armoured blocks and the unchanged creator, modifier and permission objects left out:

{
"id": "36484a18-1eb6-4863-9f50-05d9fc69ea69",
"metadata": "-----BEGIN PGP MESSAGE-----...",
"metadata_key_id": "4d1c1e97-8c67-4346-9605-839dc7ded898",
"metadata_key_type": "user_key",
"resource_type_id": "7438294d-f71c-5164-ba95-d9e60e295564",
"folder_parent_id": null,
"personal": true,
"deleted": false,
"expired": "2026-11-19T10:15:53+00:00",
"created": "2026-08-21T10:15:54+00:00",
"modified": "2026-08-21T10:15:54+00:00",
"created_by": "4bd348c4-6724-4c83-b497-a7e587fa0058",
"modified_by": "4bd348c4-6724-4c83-b497-a7e587fa0058",
"secrets": [
{
"id": "a25641a4-13ca-4971-91dc-d48278a5a94b",
"user_id": "4bd348c4-6724-4c83-b497-a7e587fa0058",
"resource_id": "36484a18-1eb6-4863-9f50-05d9fc69ea69",
"data": "-----BEGIN PGP MESSAGE-----..."
}
],
"tags": []
}

What is inside the metadata

Decrypting metadata gives a JSON object:

{
"object_type": "PASSBOLT_RESOURCE_METADATA",
"resource_type_id": "7438294d-f71c-5164-ba95-d9e60e295564",
"name": "Nextcloud",
"username": "[email protected]",
"uris": ["https://nextcloud.com"],
"description": "My personal cloud storage",
"icon": {},
"custom_fields": []
}

object_type and resource_type_id are part of the contract: object_type is the literal string PASSBOLT_RESOURCE_METADATA, and it is not the same value as the one you will find inside a decrypted secret, which is PASSBOLT_SECRET_DATA. The client entity requires only name and resource_type_id, and the server-side type definitions require only name; username and description are nullable.

uris is plural

The v4 resource had a single uri string. The v5 metadata has a uris array. This is the rename that catches integrations out, because a client reading uri from the decrypted object silently finds nothing rather than failing.

Note that resource_type_id appears twice, once in clear in the body and once inside the encrypted metadata. Keep them consistent.

What is inside the secret

Each entry of secrets carries a data field holding a second armoured message, and its cleartext is not the same shape for every resource type. For all types but one it is a JSON object:

{
"object_type": "PASSBOLT_SECRET_DATA",
"password": "iP^;YD)=o^Ch+E,&4y",
"description": "The note that travels with the password"
}

Which fields belong in it depends on the type of the resource:

Resource typeRequiredOptional
v5-defaultobject_type, passworddescription, custom_fields
v5-default-with-totpobject_type, password, totpdescription, custom_fields
v5-totp-standaloneobject_type, totp
v5-noteobject_type, description
v5-custom-fieldsobject_type, custom_fields
v5-pin-codeobject_type, pin_codedescription
v5-password-stringthe password, as a bare string
v5-password-string does not encrypt JSON

That last type is the exception to everything above. Its message holds the password itself, up to 4096 characters, with no surrounding object and therefore no object_type. A client that parses every secret as JSON breaks on it.

You will only ever read one. The server refuses to create a resource of that type, with "It is not allowed to create v5-password-string resource types.", so it reaches an instance through migration from v4 rather than through the API.

The composite fields have their own shape:

  • totp is an object requiring secret_key, up to 1024 characters, digits, between 6 and 8, and algorithm. Its period is optional.
  • pin_code is a string of 4 to 12 digits, and digits only.
  • custom_fields is an array of at most 128 entries, each requiring an id and a type, with a secret_value that can be a string of up to 20000 characters, a number or a boolean. Only the values live here: the names of the fields are in metadata.custom_fields, and the two are paired by id.
  • password accepts up to 4096 characters, and is nullable on the two types that also carry other fields.
Two fields called description

metadata.description and the description of the secret are different fields with different limits. The one in the metadata is the searchable description, capped at 10000 characters. The one in the secret is the secure note, capped at 50000, and it can only be read by decrypting. Writing to the wrong one either exposes text you meant to protect, or hides text you meant to be searchable.

object_type, and what the server cannot check

Both encrypted payloads carry object_type: PASSBOLT_RESOURCE_METADATA for the metadata, PASSBOLT_SECRET_DATA for the secret. Only the secret side requires it. Every secretDataV5* entity lists it among its required fields, while the metadata entity does not, so metadata written without it validates and displays normally.

The server cannot enforce either of them. It only ever sees an armoured string and has no key to open it, so a resource created without object_type is accepted and answered with a 200. The consequence appears later, and elsewhere: the passbolt clients validate the decrypted payload, reject it, and drop the resource from the list they display. You end up with a resource that the API returns and that nobody can see, which is the same symptom as a resource whose metadata cannot be decrypted.

Omitting it from a secret therefore produces a ghost resource rather than an error message. Set it on both payloads: the secret will not display without it, and on the metadata side it is a convention the client does not currently enforce.

Creating a v5 resource

Build the metadata object above, encrypt it, and send it as the metadata field. A POST /resources.json body looks like this:

{
"resource_type_id": "7438294d-f71c-5164-ba95-d9e60e295564",
"metadata_key_type": "user_key",
"metadata_key_id": null,
"metadata": "-----BEGIN PGP MESSAGE-----...",
"folder_parent_id": null,
"personal": true,
"expired": "2026-11-19T10:15:53.741Z",
"secrets": [
{
"data": "-----BEGIN PGP MESSAGE-----..."
}
]
}
metadata_key_id is asymmetric

You may send it as null, as above, even with metadata_key_type set to user_key. The response comes back with the field filled in. Do not assume that what you sent is what you will read.

Decrypting the metadata

Which path you take depends on metadata_key_type.

With a user key

Nothing to fetch. The message is encrypted for the user's own key, so decrypt metadata with their private key and passphrase, as you would a secret.

With a shared key

The shared metadata key is itself encrypted for each user who is allowed to use it, so decryption happens in two stages:

  1. GET /metadata/keys.json?contain[metadata_private_keys]=1.
  2. In the response, take the key whose id matches the metadata_key_id of the resource, then in its metadata_private_keys array take the entry whose user_id is the user you are acting as.
  3. Decrypt the data of that entry with the user's private key, and verify its signature. The result holds the private part of the shared metadata key.
  4. Decrypt the metadata of the resource with that key.

The key is only retrievable if it has been shared with the user you authenticate as. Cache the result for the duration of your run rather than repeating the two stages for every resource.

Session keys, an optional shortcut

GET /metadata/session-keys.json returns, encrypted for the user, a set of session keys indexed by resource. A session key decrypts the metadata of its resource directly, with no asymmetric operation, which is worth having when you walk a large vault.

Treat it as an optimisation and nothing more:

  • If the call fails, or if a resource has no session key, fall back to the paths above. The result is identical.
  • The cache is filled by the clients that opened those resources. On a vault nobody has browsed, it is empty, and there is nothing you can do about it from your side.
  • The endpoint exists since 4.10.0, so an integration targeting an older version has to work without it.

A reasonable order of operations is therefore: load the session keys once at the start and tolerate their absence, then for each resource try its session key, and otherwise resolve the key according to metadata_key_type.

A reference implementation

The passbolt Ansible lookup plugin walks exactly this path, session keys included, and is short enough to read end to end when a description in prose leaves you guessing.

When a resource never arrives

A resource whose metadata cannot be decrypted is dropped by the passbolt clients without any message, which makes it look absent rather than broken. If your own integration sees a resource that the web application does not show, that is the expected asymmetry, and the encrypted metadata settings describe how to diagnose it.