In-Place Key Metadata Update (JSON Merge-Patch)

Extend a key's lifetime, raise its usage limits, and add arbitrary metadata — all via a single RFC 7396 JSON Merge-Patch.

🔧 In-Place Key Metadata Update (JSON Merge-Patch)

Scenario: you need to extend a key's expiresAt, raise its maxUsageLimit, and add / update metadata fields — without rotating or re-creating the key. The Admin API accepts an RFC 7396 JSON Merge-Patch on the key resource.

Rules of thumb:

  • Only lifecycle-safe fields are patchable — the cryptographic material (kty, algorithm) is not patchable.
  • Field validation still runs — you can't shrink maxUsageLimit below current usage, you can't move expiresAt into the past.
  • The response includes the FULL post-patch resource so you can confirm the merge.

Prerequisites

Same two identities as the AES-256 recipe. Admin needs admin.keys.patch.


Step-by-step

export BASE_URL="https://staging.ankatech.co"
export TENANT_ID="00000000-0000-0000-0000-000000000003"
export KID="key-to-extend"
# ADMIN_TOKEN as in AES-256

# Step 1: admin creates a key with modest limits
curl -s -X POST "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"kid\":\"$KID\", \"kty\":\"ML-KEM\", \"algorithm\":\"ML-KEM-768\",
    \"purpose\":\"ENCRYPT_DECRYPT\", \"keyOps\":[\"encrypt\",\"decrypt\"],
    \"maxUsageLimit\": 1000,
    \"expiresAt\":     \"2027-01-01T00:00:00Z\",
    \"metadata\":      { \"env\": \"staging\", \"owner\": \"acme-team-a\" }
  }" | jq

# Step 2: PATCH with RFC 7396 JSON Merge-Patch
#         - Raise usage limit to 100k
#         - Extend expiration by 2 years
#         - Add a new metadata field, KEEP existing ones
curl -s -X PATCH "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/merge-patch+json" \
  -d '{
    "maxUsageLimit": 100000,
    "expiresAt":     "2029-01-01T00:00:00Z",
    "metadata": {
      "compliance": "SOC2-audit-2027-11"
    }
  }' | jq
# → new resource shows maxUsageLimit=100000, expiresAt=2029-01-01, and
#   metadata = { env, owner, compliance }  ← merged, not replaced

# Step 3: to REMOVE a metadata field, set it to null per RFC 7396
curl -s -X PATCH "$BASE_URL/api/v3/admin/tenants/$TENANT_ID/keys/$KID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/merge-patch+json" \
  -d '{ "metadata": { "env": null } }' | jq
# → metadata now = { owner, compliance }   ← "env" removed
import requests

BASE = "https://staging.ankatech.co"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
KID = "key-to-extend"
# admin token as in AES-256

# Create with modest limits
requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys", headers=admin, json={
    "kid": KID, "kty": "ML-KEM", "algorithm": "ML-KEM-768",
    "purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"],
    "maxUsageLimit": 1000,
    "expiresAt":     "2027-01-01T00:00:00Z",
    "metadata":      {"env": "staging", "owner": "acme-team-a"},
}).raise_for_status()

# PATCH — raise limit + extend + merge metadata
r = requests.patch(
    f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}",
    headers={**admin, "Content-Type": "application/merge-patch+json"},
    json={
        "maxUsageLimit": 100000,
        "expiresAt":     "2029-01-01T00:00:00Z",
        "metadata":      {"compliance": "SOC2-audit-2027-11"},
    },
)
r.raise_for_status()
after = r.json()
assert after["maxUsageLimit"] == 100000
assert set(after["metadata"]) == {"env", "owner", "compliance"}    # merged
print(f"Patched OK — metadata: {after['metadata']}")

# REMOVE a metadata field with null (RFC 7396)
r2 = requests.patch(
    f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}",
    headers={**admin, "Content-Type": "application/merge-patch+json"},
    json={"metadata": {"env": None}},
).json()
assert "env" not in r2["metadata"]
print(f"After removal: {r2['metadata']}")
import java.net.URI;
import java.net.http.*;
import com.fasterxml.jackson.databind.*;

public class InPlaceKeyPatch {
    static final String BASE_URL  = "https://staging.ankatech.co";
    static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
    static final String KID       = "key-to-extend";

    static final HttpClient   http = HttpClient.newHttpClient();
    static final ObjectMapper json = new ObjectMapper();

    public static void main(String[] args) throws Exception {
        String adminAuth = "Bearer " + System.getenv("ADMIN_TOKEN");

        // Create with modest limits
        http.send(HttpRequest.newBuilder(URI.create(
            BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys"))
            .header("Authorization", adminAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("""
                { "kid": "%s", "kty": "ML-KEM", "algorithm": "ML-KEM-768",
                  "purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"],
                  "maxUsageLimit": 1000,
                  "expiresAt": "2027-01-01T00:00:00Z",
                  "metadata": { "env": "staging", "owner": "acme-team-a" } }""".formatted(KID))).build(),
            HttpResponse.BodyHandlers.ofString());

        // PATCH (RFC 7396) — raise limit + extend + merge metadata
        var patchResp = http.send(HttpRequest.newBuilder(URI.create(
            BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys/" + KID))
            .header("Authorization", adminAuth)
            .header("Content-Type", "application/merge-patch+json")
            .method("PATCH", HttpRequest.BodyPublishers.ofString("""
                { "maxUsageLimit": 100000,
                  "expiresAt": "2029-01-01T00:00:00Z",
                  "metadata": { "compliance": "SOC2-audit-2027-11" } }""")).build(),
            HttpResponse.BodyHandlers.ofString());
        JsonNode after = json.readTree(patchResp.body());
        System.out.println("Metadata after merge: " + after.get("metadata"));

        // REMOVE a metadata field with null
        var removeResp = http.send(HttpRequest.newBuilder(URI.create(
            BASE_URL + "/api/v3/admin/tenants/" + TENANT_ID + "/keys/" + KID))
            .header("Authorization", adminAuth)
            .header("Content-Type", "application/merge-patch+json")
            .method("PATCH", HttpRequest.BodyPublishers.ofString(
                "{ \"metadata\": { \"env\": null } }")).build(),
            HttpResponse.BodyHandlers.ofString());
        System.out.println("Metadata after removal: "
            + json.readTree(removeResp.body()).get("metadata"));
    }
}
const BASE_URL = "https://staging.ankatech.co";
const TENANT_ID = "00000000-0000-0000-0000-000000000003";
const KID = "key-to-extend";

// Create
await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys`, {
  method: "POST", headers: jsonH(admin),
  body: JSON.stringify({
    kid: KID, kty: "ML-KEM", algorithm: "ML-KEM-768",
    purpose: "ENCRYPT_DECRYPT", keyOps: ["encrypt","decrypt"],
    maxUsageLimit: 1000,
    expiresAt:     "2027-01-01T00:00:00Z",
    metadata:      { env: "staging", owner: "acme-team-a" },
  }),
});

// PATCH (RFC 7396) — extend + merge metadata
const patched = await (await fetch(
  `${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys/${KID}`,
  {
    method:  "PATCH",
    headers: { ...admin, "Content-Type": "application/merge-patch+json" },
    body:    JSON.stringify({
      maxUsageLimit: 100000,
      expiresAt:     "2029-01-01T00:00:00Z",
      metadata:      { compliance: "SOC2-audit-2027-11" },
    }),
  },
)).json();
console.log(`Metadata after patch: ${JSON.stringify(patched.metadata)}`);

// REMOVE a metadata field with null
const removed = await (await fetch(
  `${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys/${KID}`,
  {
    method: "PATCH",
    headers: { ...admin, "Content-Type": "application/merge-patch+json" },
    body:    JSON.stringify({ metadata: { env: null } }),
  },
)).json();
console.log(`Metadata after removal: ${JSON.stringify(removed.metadata)}`);
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class InPlaceKeyPatch
{
    const string BASE_URL  = "https://staging.ankatech.co";
    const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
    const string KID       = "key-to-extend";

    static async Task Main()
    {
        var adminHttp = new HttpClient();
        adminHttp.DefaultRequestHeaders.Add("Authorization",
            $"Bearer {Environment.GetEnvironmentVariable("ADMIN_TOKEN")}");

        // Create with modest limits
        await adminHttp.PostAsync(
            $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
            new StringContent(JsonSerializer.Serialize(new {
                kid = KID, kty = "ML-KEM", algorithm = "ML-KEM-768",
                purpose = "ENCRYPT_DECRYPT", keyOps = new[] { "encrypt", "decrypt" },
                maxUsageLimit = 1000,
                expiresAt = "2027-01-01T00:00:00Z",
                metadata = new { env = "staging", owner = "acme-team-a" },
            }), Encoding.UTF8, "application/json"));

        // PATCH (RFC 7396) — raise limit + extend + merge metadata
        var patchReq = new HttpRequestMessage(HttpMethod.Patch,
            $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}")
        {
            Content = new StringContent(JsonSerializer.Serialize(new {
                maxUsageLimit = 100000,
                expiresAt = "2029-01-01T00:00:00Z",
                metadata = new { compliance = "SOC2-audit-2027-11" },
            }), Encoding.UTF8, "application/merge-patch+json"),
        };
        var patchResp = await adminHttp.SendAsync(patchReq);
        var after = JsonDocument.Parse(await patchResp.Content.ReadAsStringAsync()).RootElement;
        Console.WriteLine($"Metadata after merge: {after.GetProperty("metadata").GetRawText()}");

        // REMOVE a metadata field with null
        var removeReq = new HttpRequestMessage(HttpMethod.Patch,
            $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys/{KID}")
        {
            Content = new StringContent("{ \"metadata\": { \"env\": null } }",
                                        Encoding.UTF8, "application/merge-patch+json"),
        };
        var removeResp = await adminHttp.SendAsync(removeReq);
        var removed = JsonDocument.Parse(await removeResp.Content.ReadAsStringAsync()).RootElement;
        Console.WriteLine($"Metadata after removal: {removed.GetProperty("metadata").GetRawText()}");
    }
}

Non-patchable fields (partial list)

Attempting to patch these returns 400 Bad Request:

  • kid, kty, algorithm — the immutable cryptographic identity
  • createdAt, status, materialVersion — server-managed lifecycle
  • The public / private key material itself — use rotation for that

Where to go next


Did this page help you?