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
maxUsageLimitbelow current usage, you can't moveexpiresAtinto 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" removedimport 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 identitycreatedAt,status,materialVersion— server-managed lifecycle- The public / private key material itself — use rotation for that
Where to go next
- Key Lifecycle Management — for suspending / revoking (not patch-able).
PATCH /admin/…/keys/{kid}— full field list.
Updated 10 days ago
Did this page help you?