How to verify a seal
Every Lacre seal is a JSON record with nine keys: the schema version, the seal type, the state of the issue, the checklist, the risk level, the approval, who signs, when, and the hash of the previous seal. Its hash is the SHA-256 of that record in canonical form. Recomputing it needs neither Lacre nor Jira: the verifier, a single file with no dependencies, is enough, and so is any tool that follows the specification on this page.
In short
- Canonical form: RFC 8785 (JSON Canonicalization Scheme): no whitespace, keys sorted by their UTF-16 code units, numbers in their shortest form and strings with the minimal escapes.
- Unicode: before serialising, every string (values and key names) is normalised to NFC, so that an accented letter gives the same hash whether it is composed or decomposed.
- Hash: SHA-256 of the resulting UTF-8 text, in lowercase hexadecimal. The seal's identifier is
L-followed by the first ten characters of the hash. - Chain: every seal carries in
prevHashthe hash of the previous seal on the same issue (GENESISfor the first one), so the chain can be checked seal by seal.
What you need
- Node.js 22 or later (nodejs.org), on Windows, macOS or Linux.
- The verifier: verify-seal.mjs, a JavaScript file with no
dependencies that installs nothing and connects nowhere. It does not use the app's code: it implements this
specification on its own, and Lacre's tests check that both give the same results on the test vectors. From a
terminal it can also be downloaded with
curl -O https://lacreapp.com/verificador/verify-seal.mjs.
The commands on this page run in the folder that holds the verifier and the files. The first one works in any terminal; the others are written for a Linux or macOS terminal (on Windows, Git Bash or WSL).
Where the seal comes from
- The evidence sheet of a seal, in the project's Lacre settings and only for its administrators, downloads the
seal as a
lacre-<issue>-<seal>.jsonfile, ready for the verifier. - The JSON export of a project, on the same page, contains every seal of a period in the
sealslist. Each item is the same object the sheet downloads: what is stored about the seal (sealId,seq,issueKey,hash,prevHash…), its alteration, the issue description as it was sealed (description) and the record inrecord. The verifier only looks atrecordandhash; nothing else goes into the hash, not even the id of the project where it was sealed (projectId,nullon seals from before Lacre stored it).
Every seal is also copied to a property of its issue, lacre.seal.<hash>, visible at
/rest/api/3/issue/<key>/properties in the Jira API. That copy is for reference: like any issue property, anyone who
can edit the issue can change or delete it. The reference record is the one Lacre keeps in its storage; the export and
the sheet copy it to a file, and what that file proves when verified outside Jira is set out in the last section,
what it proves and what it does not.
Verifying a seal
node verify-seal.mjs lacre-LAB-6-L-0123456789.json
It prints «coincide» (matches, exit code 0) or «no coincide» (does not match, 1), the recomputed hash, the expected
one, the identifier and, below, the canonical JSON it hashed: that text is the only thing the hash proves, not how the
file is laid out. The expected hash is taken from the file's hash key or from the second argument, which overrides
it; that is how the file is checked against a hash obtained from another source:
node verify-seal.mjs record.json e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de
A record that lacks a key, belongs to another schema version or has a field of another type or shape is not verified:
it is a format error (exit code 2) that names the field's path ($.checklist[1].checkedAt) and never its value, even
if its hash matches. So is a file that repeats a key within the same object: that JSON is ambiguous (RFC 7493) and
each program would read it its own way.
Verifying a whole export
A single command verifies every seal of an export and its chain: it recomputes the hash of each record and, within each
issue and by sequence number (seq), checks that the prevHash of each record is the recomputed hash of the previous
one. If the file repeats a key within the same object, it stops without verifying anything.
node --input-type=module -e "import fs from 'node:fs';import {findDuplicateKey,verify} from './verify-seal.mjs';const t=fs.readFileSync(process.argv[1],'utf8');const k=findDuplicateKey(t);if(k===undefined){const a=new Map();const x=JSON.parse(t).seals;for(const s of [...x].sort((p,q)=>String(p.record.issue.issueId).localeCompare(String(q.record.issue.issueId))||p.seq-q.seq)){let v;try{v=verify(s.record,s.hash)}catch(e){v={matches:false,hash:'',sealId:'('+e.message+')'}}const i=s.record.issue.issueId;const b=a.get(i);const r=s.record.prevHash;console.log(s.issueKey,s.seq,v.sealId,v.matches?'matches':'DOES NOT MATCH',s.seq===1?(r==='GENESIS'?'first (GENESIS)':'NOT CHAINED'):b&&b.seq===s.seq-1?(r===b.hash?'chained':'NOT CHAINED'):r==='GENESIS'?'NOT CHAINED':b&&b.seq===s.seq?'REPEATED SEQ':b?'MISSING SEAL '+(s.seq-1):'the previous one is not in the export');a.set(i,{seq:s.seq,hash:v.hash})}console.log(x.length+' seals')}else{console.error('The export repeats the key '+k+': ambiguous JSON, not verified.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json
Each line gives the issue, the sequence number, the recomputed identifier, «matches» or «DOES NOT MATCH» and how it links to the previous seal: «chained»; «first (GENESIS)», the issue's first seal; «the previous one is not in the export», when it is from before the period or from another project (the issue was moved); «MISSING SEAL» and the number, when one is missing between two in the export; or «NOT CHAINED» and «REPEATED SEQ», which should never appear. The last line says how many seals it read, to compare with the number in the export.
To also have each seal in a file of its own, for instance to see its canonical form with the verifier, the first of
these commands writes them to a new seals folder (it stops if the folder exists or if two seals would get the same
name) and the second runs the verifier on each one; every line gives the file and «coincide», «no coincide» or the
format error:
node --input-type=module -e "import fs from 'node:fs';import {findDuplicateKey} from './verify-seal.mjs';const t=fs.readFileSync(process.argv[1],'utf8');const k=findDuplicateKey(t);if(k===undefined){fs.mkdirSync('seals');const x=JSON.parse(t).seals;for(const s of x){const n=('lacre-'+s.issueKey+'-'+s.sealId).replace(/[^A-Za-z0-9_-]/g,'_');fs.writeFileSync('seals/'+n+'.json',JSON.stringify(s,null,2)+'\n',{flag:'wx'})}console.log(x.length+' seals → seals/')}else{console.error('The export repeats the key '+k+': ambiguous JSON, not verified.');process.exitCode=2}" lacre-LAB-2026-10-01-2026-10-31.json
for f in seals/*.json; do printf '%s: ' "$f"; node verify-seal.mjs "$f" 2>&1 | head -n 1; done
The evidence sheet runs these checks inside Jira for a seal and the next one, and says so in its verdict.
The issue description
The record does not carry the issue description but its hash (issue.descriptionHash): the description is stored
separately so that it can be deleted with the project's data. When it is stored, the export and the sheet include it in
description (kind stored and the document in document), and the verifier checks that it is the sealed one:
node --input-type=module -e "import {readFileSync} from 'node:fs';import {descriptionHashOf} from './verify-seal.mjs';const s=JSON.parse(readFileSync(process.argv[1],'utf8'));const d=s.description;console.log(d.kind==='stored'?(descriptionHashOf(d.document)===s.record.issue.descriptionHash?'matches':'does not match'):'description: '+d.kind)" seals/lacre-LAB-6-L-0123456789.json
kind can also be none (the issue had no description and descriptionHash is null), tooLarge (not stored
because it exceeded 200 KiB), missing (a seal from before Lacre stored it) or unreadable. descriptionHashOf is
the whole rule: it gives the same result on the description the Jira API returns today (fields.description from
GET /rest/api/3/issue/<key>), if it has not changed.
Test vectors
Seven records with their canonical JSON, their hash and their identifier, to check any implementation of this
specification; they are the ones the app and the verifier are tested with, and their hashes never change in schema 1.
The accounts, issues and texts are fictitious. Each file carries the record in record, its canonical form in
canonical, the hash in hash and the identifier in sealId, so the verifier takes it as it is:
node verify-seal.mjs 03-check-campos-ausentes.json
And the canonical form, with another tool (on macOS, shasum -a 256 instead of sha256sum):
node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).canonical)" 03-check-campos-ausentes.json | sha256sum
| Vector | What it shows | Hash |
|---|---|---|
| 01 | a check in ASCII with no description, no custom fields, from GENESIS, with an optional item left unticked |
e137f2cd7ac20f56d546dd6654bc04fab6da529bb1a85f7ddf6335a70936b4de |
| 02 | accents, ñ and emoji; decomposed accents (NFD) in the file and composed (NFC) in the canonical form; a description | 297680ecf6c4f6841b861910da2df262f541b72be5abeb207a9b74b9f6838462 |
| 03 | missing optional fields as null, empty lists and configured custom fields with no value |
175d63b63bf695c96c8a22c5af7cd28d2de0752d42c3cd91651917355799b43b |
| 04 | a second check on the same issue: prevHash is the hash of 01 |
eff830727a40d0a5fdd74f1f6da2c92b9d7ed56555120e13eecd4fda5dcbfe21 |
| 05 | a subsequent approval chained to 04, with the same issue state and the same checklist | 4ccbedd6dfd65e69ef14cc7598ce71f7db1f362e4fc3de46f6d148cc4fcb0b3c |
| 06 | a prior approval at HIGH risk from GENESIS, with the checklist half done |
eab71d4b7e5142878b2a75c24d8aa3a82ce86cb3732252d5d9c96b3f2a5f3063 |
| 07 | a check chained to 06 with numbers not in their shortest form, null, objects and lists in custom fields, and keys out of order |
e5c07f2404af5446380725599003f34f3e73987f5f58e70031c6c3467660c619 |
The canonical form of vector 03, which is what gets hashed:
{"approval":null,"checklist":[{"checked":true,"checkedAt":"2026-10-16T10:31:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p1","required":true,"text":"Nivel de riesgo del cambio valorado"},{"checked":true,"checkedAt":"2026-10-16T10:32:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p2","required":true,"text":"Pruebas en entorno separado de producción con resultado enlazado"},{"checked":true,"checkedAt":"2026-10-16T10:33:00.000Z","checkedBy":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d","id":"p3","required":true,"text":"Plan de marcha atrás escrito"},{"checked":false,"checkedAt":null,"checkedBy":null,"id":"p4","required":false,"text":"Comunicación a las partes afectadas 📣"}],"issue":{"assignee":null,"components":[],"customFields":{"customfield_10010":null,"customfield_10020":null},"descriptionHash":"a75599dbc3ad2e4679d25e750891359c1beb3ee8be1a54fbc1278f0fc8de6493","fixVersions":[],"issueId":"10044","issueKey":"LAB-44","issueType":"Tarea","issueUpdated":"2026-10-16T10:30:00.000Z","labels":[],"priority":null,"projectKey":"LAB","reporter":"5f1a2b3c4d5e6f7a8b9c0d1e","resolution":null,"status":{"id":"10000","name":"Tareas por hacer"},"summary":"Deshabilitar el acceso SSH por contraseña"},"prevHash":"GENESIS","riskLevel":"MEDIO","schemaVersion":1,"sealType":"check","signedAt":"2026-10-16T10:35:08.000Z","signer":"712020:1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d"}
And six descriptions (ADF documents) with their canonical form and their hash, the descriptionHash that goes with
them. Each file carries the document in adf, its canonical form in canonical and the hash in hash:
| Vector | What it shows | Hash |
|---|---|---|
| 01 | a paragraph in ASCII: the simplest case | 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077 |
| 02 | the document of 01 with its keys in another order at every level: same canonical form and same hash | 4709b4392a05cf9147c488d00fbb7c3c472cbe80dab571fb803a9f4ce89d1077 |
| 03 | a decomposed «é» (NFD), ñ, Spanish quotation marks, a dash and an emoji outside the basic plane: the canonical form is in NFC | faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404 |
| 04 | the text of 03 already composed (NFC): same canonical form and same hash as 03 | faf525093931891df42c429c45d19fc192ea598ae937bc8adf3bfe6e93a37404 |
| 05 | heading, link, bold, list and code block: nested attributes and numbers in their shortest form | 69ef5777a5950aa2b0575346ddad72a0ae6bcc965a4ce0c1ef8c2dd82686fae7 |
| 06 | the description of a test issue as Jira returns it | bad5b54321230033b4b81eb6a1fd5ec774c4cd93ffa8b0669add2fe0f830851c |
Schema 1 specification
This is the rule that gives the hash of every seal with schemaVersion 1. It does not change: should another one ever
be needed, it would be schema 2, with its own rule and its own vectors, and schema 1 seals would still be verified with
this one.
The state of the issue (issue)
Every field is always present; whatever the issue lacks is null (a field is never left out). The source is the
response of GET /rest/api/3/issue/<key> from the Jira API at the time of the seal.
| Field | Type | Source and rule |
|---|---|---|
issueId |
string | the issue's id |
issueKey |
string | key |
projectKey |
string | fields.project.key |
issueType |
string | fields.issuetype.name |
summary |
string | fields.summary |
descriptionHash |
string or null |
SHA-256 of the ADF document in fields.description in canonical form; null if the issue has no description, which Jira returns as null or as the document with no nodes {"type":"doc","version":1,"content":[]} (any other document, even an empty paragraph, goes in by its hash) |
status |
object { id, name } |
fields.status.id and fields.status.name |
assignee |
string or null |
fields.assignee.accountId |
reporter |
string or null |
fields.reporter.accountId |
priority |
string or null |
fields.priority.name |
resolution |
string or null |
fields.resolution.name |
labels |
list of strings | fields.labels, sorted by UTF-16 code units (the same order as keys) |
components |
list of strings | names of fields.components, same order |
fixVersions |
list of strings | names of fields.fixVersions, same order |
issueUpdated |
string | fields.updated converted to UTC, ISO 8601 with milliseconds and a Z suffix (2026-10-16T09:15:42.000Z) |
customFields |
object id → value |
only the fields chosen by the project's administrators, with the value as the API returns it (number, string, object, list or null), lists not reordered; a chosen field the issue does not have is null; with no fields chosen, {} |
The sealed record
A record is a JSON object with exactly these nine keys, all present:
| Key | Value |
|---|---|
schemaVersion |
the number 1 |
sealType |
"check" or "approve" (approval) |
issue |
the state of the issue above at the time of the seal |
checklist |
list, in the configured order, of { id, text, required, checked, checkedBy, checkedAt }: id and text strings (text, as shown to the signer, in their language); required and checked booleans; checkedBy, the accountId of whoever ticked the item, and checkedAt, when (same format as signedAt), both null if it is not ticked |
riskLevel |
"BAJO", "MEDIO" or "ALTO" (low, medium, high): the change's risk level chosen in the panel |
approval |
null on a check seal; on an approve seal, { approverRole, mode }: approverRole, the name of the project role that approves, and mode, "posterior" (approves an earlier check) or "previa" (prior approval, before the check, which the project can require at HIGH risk) |
signer |
accountId of the signer, that of the Atlassian account used to seal |
signedAt |
the seal's date in UTC, ISO 8601 with milliseconds and a Z suffix |
prevHash |
hash of the immediately preceding seal on the same issue, of whatever type, or the string "GENESIS" for the first one |
Whatever accompanies the record in the export or on the sheet is not part of it and does not go into the hash. The shape of each field, which the verifier checks before hashing:
- Objects with exactly their keys:
issue(the sixteen above),status(id,name), eachchecklistitem (its six) andapproval(approverRole,mode). - Non-empty strings:
issueId,issueKey,projectKey,status.id,signer, each item'sid, thecheckedByof a ticked item andapproverRole;assigneeandreporter, a non-empty string ornull. - Strings:
issueType,summary,status.name, each item'stextand every element oflabels,componentsandfixVersions;priorityandresolution, a string ornull. - Dates (
issueUpdated,signedAtand thecheckedAtof a ticked item):YYYY-MM-DDTHH:MM:SS.mmmZ, and the date must exist (30 February is rejected). - Hashes:
descriptionHash,nullor 64 characters0-9a-f;prevHash,"GENESIS"or 64 characters0-9a-f. - Closed values:
sealType,riskLevelandapproval.mode, those in the table;requiredandchecked,trueorfalse. - Consistency: an unticked item has
checkedByandcheckedAtset tonull, and a ticked one has both; acheckseal hasapprovalset tonull, and anapproveseal has the object. customFields, an object; its values, any JSON.
Chain: every seal points to the previous one on its issue through prevHash. A subsequent approval follows in the
chain the check it approves; with a prior approval, the check follows the approval. The order is checked through the
prevHash values and compared with the dates (signedAt).
The canonical form
The record is serialised as canonical JSON according to RFC 8785 (JSON Canonicalization Scheme):
- no whitespace or line breaks outside strings;
- the keys of each object sorted by their UTF-16 code units, comparing the key (already in NFC, see below) and not its
serialised form: this is the string comparison of JavaScript and Java, which is not alphabetical (
"Z"before"a","é"after"z", an emoji before"fi"); - numbers in ECMAScript's shortest form (
Number.prototype.toString):4.50→4.5,1E-7→1e-7,-0→0,1000000000000000000000→1e+21,100000000000000000000without an exponent;NaNand infinities are not allowed; numbers are read as IEEE 754 doubles, so an integer above 2^53 loses precision exactly as in JavaScript (an implementation with exact integers must round it the same way); - strings with the RFC's minimal escapes:
\",\\,\b,\f,\n,\r,\t, the other control characters (U+0000 to U+001F) as\u00xxin lowercase and everything else as is (accents, the euro sign, emoji, DEL; the slash/is not escaped); null,trueandfalseas literals; lists keep their order.
Rule added to the RFC: every string, values and key names alike, is normalised to Unicode NFC before serialising (and
keys are sorted once normalised), so that cafe followed by the combining accent U+0301 and café with U+00E9 give the
same hash. NFC is not NFKC: fi (U+FB01) stays as it is; U+FB33 (dalet with dagesh), on the other hand, is decomposed
into U+05D3 U+05BC, because Unicode excludes it from composition. These are errors, never resolved silently: malformed
strings (lone UTF-16 surrogates, which UTF-8 cannot encode), values that are not JSON, two keys of an object that are
equal after NFC, and JSON text that repeats a key within the same object (it must be I-JSON, RFC 7493).
The hash and the identifier
Hash: SHA-256 of the UTF-8 text of the canonical JSON, in lowercase hexadecimal (64 characters). The seal's
identifier, to talk about it: L- followed by the first ten characters of the hash (L-e137f2cd7a).
What it proves and what it does not
The verifier checks that a file is consistent: that each record gives exactly its hash and that each seal links to the previous one. It does not know where the file comes from: whoever holds it can change a record and recompute its hash and those of the following seals, and the file would still say «coincide». To know that it is the one Lacre stored, its hashes must be compared with a copy that does not depend on whoever hands over the file: what Lacre shows inside Jira (the panel's history with "Verify", the evidence sheet or an export made in front of the auditor), an earlier export the auditor already has, or a hash noted somewhere else, passed to the verifier as the second argument.
With that, the seal proves that the record has not changed since it was stored (the hash and the chain) and what the issue looked like when it was sealed. It does not prove the identity of whoever signed beyond the Atlassian account they were logged in with, and it adds no third-party timestamp. What counts as evidence in an audit is for the auditor to decide.