diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 3404dc6dda..86d6b5046d 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -156,6 +156,7 @@
"JWT Sign",
"JWT Verify",
"JWT Decode",
+ "JWT Decode and Verify",
"Citrix CTX1 Encode",
"Citrix CTX1 Decode",
"AES Key Wrap",
diff --git a/src/core/operations/JWTDecodeAndVerify.mjs b/src/core/operations/JWTDecodeAndVerify.mjs
new file mode 100644
index 0000000000..d08767b488
--- /dev/null
+++ b/src/core/operations/JWTDecodeAndVerify.mjs
@@ -0,0 +1,175 @@
+/**
+ * JWT Decode and Verify operation.
+ *
+ * Shows the decoded header, decoded payload, and signature verification
+ * status (valid / invalid / not verified) in a single JSON output.
+ *
+ * @author hl6226
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import jwt from "jsonwebtoken";
+import r from "jsrsasign";
+import OperationError from "../errors/OperationError.mjs";
+import {JWT_ALGORITHMS} from "../lib/JWT.mjs";
+
+/**
+ * JWT Decode and Verify operation
+ */
+class JWTDecodeAndVerify extends Operation {
+
+ /**
+ * JWTDecodeAndVerify constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "JWT Decode and Verify";
+ this.module = "Ciphers";
+ this.description = "Decodes a JSON Web Token and displays the header, payload, and signature verification status.
Provide a secret (for HMAC), PEM-encoded public key (for RSA/ECDSA) or JSON format public key (for JWKS) to verify the signature. Leave the key blank to decode without verification.
Output includes the decoded header, payload, and signature status: verified, invalid, or not checked.";
+ this.infoURL = "https://wikipedia.org/wiki/JSON_Web_Token";
+ this.inputType = "string";
+ this.outputType = "JSON";
+ this.presentType = "html";
+ this.args = [
+ {
+ name: "Public/Secret Key (optional)",
+ type: "text",
+ value: "",
+ hint: "Leave blank to decode without verifying the signature. To verify the signature, enter the public key as a secret (for HMAC), PEM-encoded public key (for RSA/ECDSA) or JSON format public key (for JWKS)"
+ }
+ ];
+ this.checks = [
+ {
+ // Standard JWT: header.payload.signature (all base64url)
+ pattern: "^ey[A-Za-z0-9_-]+\\.ey[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]*$",
+ flags: "",
+ args: [""]
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {Object}
+ */
+ run(input, args) {
+ const [key] = args;
+ input = input.trim();
+
+ // Decode without verification first to extract header + payload
+ let decoded;
+ try {
+ decoded = jwt.decode(input, { complete: true, json: true });
+ } catch (err) {
+ throw new OperationError(`Failed to decode JWT: ${err.message}`);
+ }
+
+ if (!decoded) {
+ throw new OperationError(
+ "Invalid JWT: could not decode token. " +
+ "Ensure the input is a valid base64url-encoded JWT."
+ );
+ }
+
+ const result = {
+ header: decoded.header,
+ payload: decoded.payload,
+ signatureVerified: "not verified",
+ signatureWarning: "No key provided — signature was not checked."
+ };
+
+ // Attempt signature verification if a key was supplied
+ if (key && key.trim() !== "") {
+ const algos = JWT_ALGORITHMS.map(a => (a === "None" ? "none" : a));
+ try {
+ const alg = decoded.header.alg || "";
+ let resolvedKey = key.trim();
+ if (resolvedKey.startsWith("{") || resolvedKey.startsWith("[")) {
+ const parsed = JSON.parse(resolvedKey);
+ resolvedKey = Array.isArray(parsed) ? parsed[0] :
+ Array.isArray(parsed.keys) ? parsed.keys[0] : parsed;
+ }
+ const verifyKey = alg.startsWith("HS") ? { utf8: key.trim() } : r.KEYUTIL.getKey(resolvedKey);
+ const valid = r.KJUR.jws.JWS.verify(input, verifyKey, algos);
+ if (!valid) throw new Error("invalid signature");
+ result.signatureVerified = true;
+ result.signatureWarning = "Signature verified successfully.";
+ } catch (err) {
+ result.signatureVerified = false;
+ result.signatureWarning = `Signature verification failed: ${err.message}`;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Presents the result as a formatted, human-readable string.
+ *
+ * @param {Object} data
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ present(data, args) {
+ if (typeof data !== "object" || data === null) return String(data);
+
+ const G = "#52af6d"; // green — signature
+ const O = "#e08030"; // orange — header
+ const P = "#b090d0"; // purple — payload
+ const R = "#761c17"; // red — failed
+
+ const esc = s => String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+
+ const colorJson = obj => esc(JSON.stringify(obj, null, 2))
+ .replace(/\n/g, "
")
+ .replace(/ /g, " ");
+
+ // Expiry notes
+ let expiryNote = "";
+ if (data.payload && typeof data.payload.exp === "number") {
+ const expDate = new Date(data.payload.exp * 1000);
+ const expired = expDate.getTime() < Date.now();
+ expiryNote += `
exp : ${esc(expDate.toUTCString())}${expired ? " ⚠ TOKEN EXPIRED" : " ✓ not yet expired"}`;
+ }
+ if (data.payload && typeof data.payload.nbf === "number") {
+ const nbfDate = new Date(data.payload.nbf * 1000);
+ const notYet = nbfDate.getTime() > Date.now();
+ expiryNote += `
nbf : ${esc(nbfDate.toUTCString())}${notYet ? " ⚠ TOKEN NOT YET VALID" : " ✓ valid"}`;
+ }
+ if (data.payload && typeof data.payload.iat === "number") {
+ const iatDate = new Date(data.payload.iat * 1000);
+ expiryNote += `
iat : ${esc(iatDate.toUTCString())}`;
+ }
+
+ const sigIcon = data.signatureVerified === true ? "✓" :
+ data.signatureVerified === false ? "✗" : "?";
+ const sigColor = data.signatureVerified === true ? G :
+ data.signatureVerified === false ? R : "";
+ const sigMsg = sigColor ?
+ `${esc(data.signatureWarning)}` :
+ esc(data.signatureWarning);
+
+ const hr = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
+
+ return [
+ hr, "