Sign-then-Encrypt (nested JWE(JWS))

Produce a single token that is both signed (authenticity) and encrypted (confidentiality) — nested JWE(JWS) in one API call.

🔏 Sign-then-Encrypt (nested JWE(JWS))

Scenario: produce a token that is both signed (authenticity + integrity) and encrypted (confidentiality) — the RFC-blessed pattern is sign-then-encrypt (never the reverse). The platform does both in a single call, returning a nested JWE(JWS) where the outer JWE wraps an inner JWS with the payload.

Common in B2B message exchange where the recipient must (a) verify the sender's identity and (b) keep the payload confidential from intermediaries.

Prerequisites

Same two identities as the AES-256 recipe. You need two keys in the tenant: a signing key + an encryption key.


Step-by-step

export BASE_URL="https://staging.ankatech.co"
export TENANT_ID="00000000-0000-0000-0000-000000000003"
export SIGN_KID="acme-signer"
export ENC_KID="acme-encryptor"
# ADMIN_TOKEN + APP_TOKEN as in AES-256 recipe

# Step 2: admin provisions the two keys
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\":\"$SIGN_KID\", \"kty\":\"ML-DSA\", \"algorithm\":\"ML-DSA-65\",
        \"purpose\":\"SIGN_VERIFY\", \"keyOps\":[\"sign\",\"verify\"] }" | jq

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\":\"$ENC_KID\", \"kty\":\"ML-KEM\", \"algorithm\":\"ML-KEM-768\",
        \"purpose\":\"ENCRYPT_DECRYPT\", \"keyOps\":[\"encrypt\",\"decrypt\"] }" | jq

# Step 3: app calls sign-encrypt — one round-trip, two crypto primitives
export DATA_B64=$(printf "%s" "Signed AND encrypted payload" | base64 -w0)
curl -s -X POST "$BASE_URL/api/v3/crypto/sign-encrypt" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"signKid\":    \"$SIGN_KID\",
    \"encryptKid\": \"$ENC_KID\",
    \"data\":       \"$DATA_B64\"
  }" | tee /tmp/se.json | jq '{signAlgorithmUsed, encryptAlgorithmUsed}'

# Step 4: recipient calls decrypt-verify — reverses both in a single call
export JWE=$(jq -c .jweToken /tmp/se.json)
curl -s -X POST "$BASE_URL/api/v3/crypto/decrypt-verify" \
  -H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
  -d "{ \"jweToken\": $JWE }" \
  | jq '{decryptedData, signatureValid, signerKid}'
# → signatureValid: true, signerKid: "acme-signer"
import base64, requests

BASE = "https://staging.ankatech.co"
TENANT_ID = "00000000-0000-0000-0000-000000000003"
SIGN_KID, ENC_KID = "acme-signer", "acme-encryptor"

# Admin provisions both keys
for spec in [
    {"kid": SIGN_KID, "kty": "ML-DSA", "algorithm": "ML-DSA-65",
     "purpose": "SIGN_VERIFY",     "keyOps": ["sign","verify"]},
    {"kid": ENC_KID,  "kty": "ML-KEM", "algorithm": "ML-KEM-768",
     "purpose": "ENCRYPT_DECRYPT", "keyOps": ["encrypt","decrypt"]},
]:
    requests.post(f"{BASE}/api/v3/admin/tenants/{TENANT_ID}/keys",
                  headers=admin, json=spec).raise_for_status()

# Sign-then-encrypt in a single call
data_b64 = base64.b64encode(b"Signed AND encrypted payload").decode()
se = requests.post(f"{BASE}/api/v3/crypto/sign-encrypt", headers=app, json={
    "signKid":    SIGN_KID,
    "encryptKid": ENC_KID,
    "data":       data_b64,
}).json()
print(f"Sign={se['signAlgorithmUsed']}  Encrypt={se['encryptAlgorithmUsed']}")

# Recipient: decrypt-then-verify in a single call
dv = requests.post(f"{BASE}/api/v3/crypto/decrypt-verify", headers=app,
                   json={"jweToken": se["jweToken"]}).json()
assert dv["signatureValid"], "Signature failed"
assert base64.b64decode(dv["decryptedData"]) == b"Signed AND encrypted payload"
print(f"Verified signer: {dv['signerKid']}")
import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import com.fasterxml.jackson.databind.*;

public class SignThenEncrypt {
    static final String BASE_URL  = "https://staging.ankatech.co";
    static final String TENANT_ID = "00000000-0000-0000-0000-000000000003";
    static final String SIGN_KID  = "acme-signer";
    static final String ENC_KID   = "acme-encryptor";

    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");
        String appAuth   = "Bearer " + System.getenv("APP_TOKEN");

        // Admin provisions two keys
        for (String[] spec : new String[][] {
            { SIGN_KID, "ML-DSA", "ML-DSA-65",  "SIGN_VERIFY",     "\"sign\",\"verify\""    },
            { ENC_KID,  "ML-KEM", "ML-KEM-768", "ENCRYPT_DECRYPT", "\"encrypt\",\"decrypt\"" },
        }) {
            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(String.format(
                    "{ \"kid\": \"%s\", \"kty\": \"%s\", \"algorithm\": \"%s\","
                    + " \"purpose\": \"%s\", \"keyOps\": [%s] }",
                    spec[0], spec[1], spec[2], spec[3], spec[4]))).build(),
                HttpResponse.BodyHandlers.ofString());
        }

        // Sign-then-encrypt in a single call
        var dataB64 = Base64.getEncoder().encodeToString("Signed AND encrypted payload".getBytes());
        var seResp = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/sign-encrypt"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(String.format(
                "{ \"signKid\": \"%s\", \"encryptKid\": \"%s\", \"data\": \"%s\" }",
                SIGN_KID, ENC_KID, dataB64))).build(),
            HttpResponse.BodyHandlers.ofString());
        JsonNode se = json.readTree(seResp.body());
        System.out.println("sign=" + se.get("signAlgorithmUsed").asText()
            + "  encrypt=" + se.get("encryptAlgorithmUsed").asText());

        // Decrypt-then-verify
        var dvResp = http.send(HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v3/crypto/decrypt-verify"))
            .header("Authorization", appAuth).header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                "{ \"jweToken\": " + se.get("jweToken") + " }")).build(),
            HttpResponse.BodyHandlers.ofString());
        JsonNode dv = json.readTree(dvResp.body());
        if (!dv.get("signatureValid").asBoolean()) throw new RuntimeException("Verify failed");
        System.out.println("Verified signer: " + dv.get("signerKid").asText());
    }
}
const BASE_URL = "https://staging.ankatech.co";
const TENANT_ID = "00000000-0000-0000-0000-000000000003";
const SIGN_KID = "acme-signer", ENC_KID = "acme-encryptor";

for (const spec of [
  { kid: SIGN_KID, kty: "ML-DSA", algorithm: "ML-DSA-65",
    purpose: "SIGN_VERIFY",     keyOps: ["sign","verify"] },
  { kid: ENC_KID,  kty: "ML-KEM", algorithm: "ML-KEM-768",
    purpose: "ENCRYPT_DECRYPT", keyOps: ["encrypt","decrypt"] },
]) {
  await fetch(`${BASE_URL}/api/v3/admin/tenants/${TENANT_ID}/keys`, {
    method: "POST", headers: jsonH(admin), body: JSON.stringify(spec),
  });
}

// Sign-then-encrypt in one call
const dataB64 = Buffer.from("Signed AND encrypted payload").toString("base64");
const se = await (await fetch(`${BASE_URL}/api/v3/crypto/sign-encrypt`, {
  method: "POST", headers: jsonH(app),
  body: JSON.stringify({ signKid: SIGN_KID, encryptKid: ENC_KID, data: dataB64 }),
})).json();

// Decrypt-then-verify
const dv = await (await fetch(`${BASE_URL}/api/v3/crypto/decrypt-verify`, {
  method: "POST", headers: jsonH(app),
  body: JSON.stringify({ jweToken: se.jweToken }),
})).json();
if (!dv.signatureValid) throw new Error("Verify failed");
console.log(`Verified signer: ${dv.signerKid}`);
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class SignThenEncrypt
{
    const string BASE_URL  = "https://staging.ankatech.co";
    const string TENANT_ID = "00000000-0000-0000-0000-000000000003";
    const string SIGN_KID  = "acme-signer";
    const string ENC_KID   = "acme-encryptor";

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

        // Admin provisions two keys
        foreach (var spec in new[] {
            new { kid = SIGN_KID, kty = "ML-DSA", algorithm = "ML-DSA-65",
                  purpose = "SIGN_VERIFY",     keyOps = new[] { "sign", "verify" } },
            new { kid = ENC_KID,  kty = "ML-KEM", algorithm = "ML-KEM-768",
                  purpose = "ENCRYPT_DECRYPT", keyOps = new[] { "encrypt", "decrypt" } },
        }) {
            await adminHttp.PostAsync(
                $"{BASE_URL}/api/v3/admin/tenants/{TENANT_ID}/keys",
                new StringContent(JsonSerializer.Serialize(spec),
                                  Encoding.UTF8, "application/json"));
        }

        // Sign-then-encrypt in a single call
        var dataB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("Signed AND encrypted payload"));
        var seResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/sign-encrypt",
            new StringContent(JsonSerializer.Serialize(new {
                signKid = SIGN_KID, encryptKid = ENC_KID, data = dataB64,
            }), Encoding.UTF8, "application/json"));
        var se = JsonDocument.Parse(await seResp.Content.ReadAsStringAsync()).RootElement;
        Console.WriteLine($"sign={se.GetProperty("signAlgorithmUsed").GetString()}"
            + $"  encrypt={se.GetProperty("encryptAlgorithmUsed").GetString()}");

        // Decrypt-then-verify
        var jweToken = se.GetProperty("jweToken").GetRawText();
        var dvResp = await appHttp.PostAsync(
            $"{BASE_URL}/api/v3/crypto/decrypt-verify",
            new StringContent($"{{ \"jweToken\": {jweToken} }}",
                              Encoding.UTF8, "application/json"));
        var dv = JsonDocument.Parse(await dvResp.Content.ReadAsStringAsync()).RootElement;
        if (!dv.GetProperty("signatureValid").GetBoolean()) throw new Exception("Verify failed");
        Console.WriteLine($"Verified signer: {dv.GetProperty("signerKid").GetString()}");
    }
}

Why sign-then-encrypt (never the reverse)

  • Signed-then-encrypted proves the signer knew the plaintext — the ciphertext is opaque to intermediaries, but the signature travels sealed with it.
  • Encrypt-then-sign would let an attacker strip the outer signature, re-sign the ciphertext with their own key, and impersonate the sender.
  • The nested JWE(JWS) has cty=JWS in its outer JOSE header, standardising the structure per RFC 7519 §11.2.

Where to go next


Did this page help you?