DEV Community

Xiao Ling
Xiao Ling

Posted on Originally published at dynamsoft.com

How to Generate a Boarding Pass Barcode in JavaScript (IATA BCBP)

The barcode on a boarding pass carries a payload defined by IATA Resolution 792 — an IATA Bar Coded Boarding Pass (BCBP). It is a fixed-order layout with mandatory and conditional sections, strictly sized fields, and two nested hexadecimal size fields that a reader has to follow to find where anything ends.

That structure is what makes a boarding pass worth generating rather than typing text into a generic QR generator. A payload is only useful as a test case if it is well formed — correct widths, correct padding, consistent size fields — and if you know what the scanner should return, so that a wrong or missing field stands out.

This article builds that generator: it composes a BCBP payload, signs the security section (items 25–30) with WebCrypto so an edited field can be caught later, renders the result as PDF417, Aztec Code, QR Code or Data Matrix on a printed or mobile pass preview, and prints the field-by-field result a conformant scanner should return.

A generated boarding pass: SEA to SFO on UA 1234, seat 18A, with a PDF417 symbol and a SPECIMEN watermark

What you'll build: a browser-side BCBP generator — the Resolution 792 encoder, the nested size fields, one-to-four flight legs, four symbologies, a security section that re-signs itself whenever a field changes, a canvas pass preview that marks what Randomise changed, and a self-check that parses the page's own output.

Online demo

Demo Video

Prerequisites

  • Node.js or any static file server for local development. The page itself has no build step.
  • A browser with an HTML5 canvas — the symbol and the preview are both drawn client-side.
  • bwip-js (BWIPP compiled to JavaScript), loaded from a CDN. It is the only third-party dependency.

Step 1: Lay out the BCBP field table

Start by writing down the layout, because every later step depends on these widths. The unique mandatory section is 23 characters:

Pos Len Item Field
1 1 1 Format code, always M
2 1 5 Number of legs encoded (1–4)
3 20 11 Passenger name, SURNAME/GIVEN NAME, left-justified
23 1 253 Electronic ticket indicator, E or blank

Then a 35-character block that repeats for every leg:

Pos Len Item Field
24 7 7 Operating carrier PNR
31 3 26 From airport (IATA)
34 3 38 To airport (IATA)
37 3 42 Operating carrier designator
40 5 43 Flight number, 4 digits + optional letter
45 3 46 Date of flight, Julian day of year
48 1 71 Compartment code
49 4 104 Seat number, 3-digit row + letter
53 5 107 Check-in sequence number
58 1 113 Passenger status

Positions 59–60 are item 6, the size field. Encode the fixed part first, with the padding rules spelled out:

function encodeMandatory(r, leg, index, variableSize) {
  var out = '';
  if (index === 0) {
    out += FORMAT_CODE;                                 // item 1
    out += String(r.legs.length);                       // item 5
    out += padRight(r.passengerName, SIZE.passengerName, ' '); // item 11
    out += r.electronicTicket ? 'E' : ' ';              // item 253
  }
  out += padRight(leg.pnr, 7, ' ');                     // item 7
  out += padRight(leg.from, 3, ' ');                    // item 26
  out += padRight(leg.to, 3, ' ');                      // item 38
  out += padRight(leg.carrier, 3, ' ');                 // item 42
  out += encodeFlightNumber(leg.flightNumber);          // item 43
  out += padLeft(leg.julianDate, 3, '0');               // item 46
  out += padRight(leg.compartment, 1, ' ');             // item 71
  out += encodeSeat(leg.seat);                          // item 104
  out += encodeSequence(leg.sequence);                  // item 107
  out += padRight(leg.passengerStatus, 1, ' ');         // item 113
  out += hex2(variableSize);                            // item 6
  return out;
}
Enter fullscreen mode Exit fullscreen mode

The padding rules are the part that catches people out. Alphanumeric items are left-justified with trailing blanks; numeric items carry leading zeros. The seat and the sequence number look similar but pad differently:

function encodeSeat(value) {
  var v = upper(value).replace(/\s+/g, '');
  if (!v) return '    ';
  if (v === 'INF') return 'INF ';
  var m = /^(\d{1,3})([A-Z]{1,2})$/.exec(v);
  if (!m) {
    throw new Error('Seat "' + value
      + '" is not valid: use a row and a letter (18A), or INF for an infant on a separate pass.');
  }
  return m[1].padStart(3, '0') + m[2];
}

function encodeSequence(value) {
  var d = digitsOnly(value);
  if (!d) return '     ';
  if (d.length >= 5) return d.slice(0, 5);
  /* The IATA examples print four digits followed by a blank, so pad to four
     and leave the fifth position as the guide's trailing space. */
  return padRight(d.padStart(4, '0'), 5, ' ');
}
Enter fullscreen mode Exit fullscreen mode

Seat 18A becomes 018A — leading zeros. Sequence 42 becomes 0042 — leading zeros and a trailing blank. The official Resolution 792 examples spell it that way (0025, 0027), and a scanner has to cope with that exact spelling, so the generator reproduces it rather than choosing the tidier-looking right-aligned form.

Step 2: Pack the conditional sections in fixed order

The variable field holds three things, one after another: the once-per-pass data, the once-per-leg data, and the carrier's own space. Both conditional blocks are a fixed sequence of slots with known widths, which is what lets a reader parse them without tags:

var UNIQUE_SLOTS = [
  { code: 'passengerDescription', label: 'Passenger description', item: 15, len: 1 },
  { code: 'checkinSource', label: 'Source of check-in', item: 12, len: 1 },
  // ... the rest, in the order listed below
];

var REPEATED_SLOTS = [
  { code: 'airlineNumericCode', label: 'Airline numeric code', item: 142, len: 3, numeric: true },
  // ... the rest, in the order listed below
];
Enter fullscreen mode Exit fullscreen mode

The complete once-per-pass block (item 10):

Item Len Field
15 1 Passenger description
12 1 Source of check-in
14 1 Source of issuance
22 4 Date of issue — last digit of the year plus the Julian day
16 1 Document type
21 3 Airline designator of the issuer
23 13 Baggage tag; up to three, so up to 39 characters
31 13 Non-consecutive baggage tag
32 13 Second non-consecutive baggage tag

And the once-per-leg block (item 17):

Item Len Field
142 3 Airline numeric code
143 10 Document form / serial number
18 1 Selectee indicator
108 1 International documentation verification
19 3 Marketing carrier designator
20 3 Frequent flyer airline
236 16 Frequent flyer number
89 1 ID / AD indicator
118 3 Free baggage allowance
254 1 Fast track

Packing then means concatenating slots until the last non-empty one, and dropping only the trailing empties:

function packSlots(slots, values) {
  var last = -1;
  for (var i = 0; i < slots.length; i++) {
    if (str(values[slots[i].code]).trim() !== '') last = i;
  }
  if (last === -1) return '';
  var out = '';
  for (var j = 0; j <= last; j++) {
    var slot = slots[j];
    var value = str(values[slot.code]);
    out += slot.numeric ? padLeft(value, slot.len, '0') : padRight(value, slot.len, ' ');
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Because the block is positional, a field can only be present if every field before it is present too. One consequence is worth surfacing in the UI: if a user fills in a later item and leaves an earlier one blank, that blank still gets packed, with its own padding character — data the user never entered. Writing a frequent flyer number therefore forces the free baggage allowance in front of it to appear as blanks.

The validator looks for that gap and reports it instead of silently inventing the filler:

    /* Interior gaps: the packed block will contain a filler the user did not
       type. Report it instead of silently inventing data. */
    var filled = firstInteriorGap(UNIQUE_SLOTS, {
      passengerDescription: r.passengerDescription,
      checkinSource: r.checkinSource,
      // ... one entry per slot, in the same order as UNIQUE_SLOTS
    });
    if (filled) {
      problems.push('The optional fields are written in a fixed order, so "' + filled
        + '" cannot be included while an earlier optional field is left out.');
    }
Enter fullscreen mode Exit fullscreen mode

One block remains at the very end of the variable field: the security section, items 25–30. It is not typed into the form — it is signed — and Step 8 covers it.

Step 3: Write the size fields

This is the step that breaks generated passes. The variable field is not self-delimiting; it is measured by the field in front of it, and it contains two smaller size fields of its own.

Writing it is easiest if you assemble the bytes first and then take their length:

var blocks = r.legs.map(function (leg, index) {
  var repeated = encodeRepeated(leg);
  var airlineUse = leg.airlineUse;
  var body;
  if (index === 0) {
    /* A "version 0" pass predates the version marker. Item 6 is then 00 and
       nothing at all follows it — not even the item 17 size field. */
    if (r.version === '0') {
      body = '';
    } else {
      var unique = encodeUnique(r);
      body = '>' + r.version + hex2(unique.length) + unique
        + hex2(repeated.length) + repeated + airlineUse;
    }
  } else {
    body = hex2(repeated.length) + repeated + airlineUse;
  }
  /* item 6 counts everything that follows it, including the nested size
     fields: item 8 + item 9 (2), item 10's two hex digits (2) and item 17's
     two hex digits (2). Taking the length of the assembled block is the one
     definition that cannot drift out of step with the bytes. */
  return { size: body.length, body: body };
});
Enter fullscreen mode Exit fullscreen mode

Reading the assembled block back makes the arithmetic self-evident:

  • > plus the version digit — 2 bytes (items 8 and 9)
  • the item 10 size field — 2 bytes
  • the unique conditional data
  • the item 17 size field — 2 bytes
  • the repeated conditional data
  • the airline's own data (item 4), which is whatever remains

For the official single-leg example with no conditional data, that gives item 6 = 06:

M1DESMARAIS/LUC       EABC123 YULFRAAC 0834 226F001A0025 106>60000
                                                              ^^ item 6 = 6 bytes
                                                                >60000  ->  '>', '6', item10='00', item17='00'
Enter fullscreen mode Exit fullscreen mode

A later leg has no unique block, so its size field is just the repeated size plus the carrier data:

body = hex2(repeated.length) + repeated + airlineUse;
Enter fullscreen mode Exit fullscreen mode

The page shows the assembled payload so you can count the bytes yourself:

The encoded BCBP payload: the mandatory sections, then the hexadecimal size fields and the variable block

Step 4: Support more than one leg

The mandatory block repeats per leg, but the 23-character header does not — the passenger name is written once. The PNR, however, sits inside the repeating block, so a two-leg pass can carry a different locator on each leg. The official Resolution 792 two-leg example does exactly that:

M2DESMARAIS/LUC       EAB12C3 YULFRAAC 0834 326J003A0027 167>5321WW1325BAC ... 4PCYLX58ZDEF456 FRAGVALH 3664 327C012C0002 12E2A0140987654321 ...
                     ^^^^^^ PNR on leg 1                                 ^^^^^^ PNR on leg 2
Enter fullscreen mode Exit fullscreen mode

So the record keeps the PNR per leg, and the form propagates an edit to every leg that is still carrying the previous value:

els.pnr.addEventListener('input', function () {
  var previous = state.lastRecordPnr || '';
  var next = els.pnr.value;
  legEditors().forEach(function (editor) {
    var el = editor.querySelector('[data-field="pnr"]');
    if (el && (el.value === previous || el.value === '')) el.value = next;
  });
  state.lastRecordPnr = next;
  schedule();
});
Enter fullscreen mode Exit fullscreen mode

One thing to watch: the free baggage allowance (item 118) is also per leg, so a second leg with no allowance omits it — while the PNR, in the same block, still appears. A two-leg pass is a good test of your size fields, because there are three of them to get wrong instead of one.

Step 5: Render the symbol with bwip-js

The payload is a plain string, so rendering is the easy part. Map each symbology to its BWIPP encoder name and a module size:

var FORMATS = {
  pdf417:     { bcid: 'pdf417',     label: 'PDF417',      scale: 5, rowHeight: 5, linear: true, minVersion: 1 },
  azteccode:  { bcid: 'azteccode',  label: 'Aztec Code',  scale: 7, minVersion: 7 },
  qrcode:     { bcid: 'qrcode',     label: 'QR Code',     scale: 7, minVersion: 7 },
  datamatrix: { bcid: 'datamatrix', label: 'Data Matrix', scale: 7, minVersion: 7 }
};

function renderSymbol(payload, formatId) {
  var format = FORMATS[formatId];
  var options = {
    bcid: format.bcid,
    text: payload,
    scale: format.scale,
    includetext: false,
    /* A quiet zone is part of the specification: a symbol that touches its
       own border is materially harder for a scanner to lock onto. */
    paddingwidth: 4,
    paddingheight: 4
  };
  if (format.linear) options.height = format.rowHeight;

  var canvas = document.createElement('canvas');
  return new Promise(function (resolve, reject) {
    var pending;
    try {
      pending = window.bwipjs.toCanvas(canvas, options);
    } catch (error) {
      reject(error);
      return;
    }
    if (pending && typeof pending.then === 'function') {
      pending.then(function () { resolve(canvas); }, reject);
    } else {
      resolve(canvas);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

minVersion is not cosmetic. Aztec Code, QR Code and Data Matrix were only added to the standard for printed passes in version 7, so picking one of them silently sets the version field to 7. A payload that claims version 6 while travelling in a QR Code claims a combination the standard did not allow.

Also worth knowing: a symbol exported from a canvas is transparent by default. toCanvas leaves the background unset, so a PNG saved straight from it carries an alpha channel. Handed those pixels as RGBA, a reader treats (0,0,0,0) as black, which floods the quiet zone with ink and hides the symbol. Fill the canvas white first, or pass backgroundcolor: 'FFFFFF' to the encoder.

Step 6: Draw a pass preview

A preview that looks like a boarding pass is easier to sanity-check than a bare symbol: you can see at a glance that the name, route and seat are the ones you typed. The drawing is ordinary Canvas 2D; the parts worth calling out are the watermark and the symbol panel:

function drawSpecimen(ctx, x, y, size) {
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(-Math.PI / 10);
  ctx.font = 'bold ' + size + 'px ' + FONT;
  ctx.fillStyle = 'rgba(200, 16, 46, 0.09)';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('SPECIMEN', 0, 0);
  ctx.restore();
}
Enter fullscreen mode Exit fullscreen mode

The security card — item 28 and the auto-signed item 30 with a live verdict and a Re-sign button — above the flight leg fields, PNR for this leg included

The symbol is drawn into a tinted panel and scaled to fit, which keeps it away from the pass border — the same quiet-zone reasoning as paddingwidth above:

var availW = panelW - inner * 2;
var availH = (panelBottom - panelTop) - labelH - inner;
var scale = Math.min(availW / symbol.width, availH / symbol.height, 1);
var drawW = Math.max(1, Math.round(symbol.width * scale));
var drawH = Math.max(1, Math.round(symbol.height * scale));
ctx.imageSmoothingEnabled = scale < 1;
ctx.drawImage(symbol,
  panelX + (panelW - drawW) / 2,
  panelTop + labelH + (availH - drawH) / 2,
  drawW, drawH);
Enter fullscreen mode Exit fullscreen mode

The same payload also renders as a mobile wallet pass, which is the layout a phone would show:

The same payload rendered as a mobile wallet pass with a QR Code

Marking what Randomise changed

A button that rewrites a dozen controls at once is disorienting: nothing tells you which values moved, so a field you had set deliberately looks the same as one that was overwritten. Snapshotting the form before the rewrite and comparing after fixes that in a few lines:

function highlightChanges(before) {
  if (!before) return 0;
  var changed = 0;

  function mark(el, previous) {
    if (!el || previous === undefined) return;
    if (previous === el.value) {
      el.classList.remove('just-changed');
      return;
    }
    flash(el);
    changed++;
  }

  HIGHLIGHT_FIELDS.forEach(function (id) { mark(els[id], before[id]); });
  legEditors().forEach(function (editor, index) {
    HIGHLIGHT_LEG_FIELDS.forEach(function (name) {
      mark(editor.querySelector('[data-field="' + name + '"]'),
        before['leg' + index + '.' + name]);
    });
  });

  return changed;
}

function flash(el) {
  el.classList.remove('just-changed');
  /* Reading offsetWidth flushes the removal, so the animation replays even
     when the same control changes on two consecutive clicks. */
  void el.offsetWidth;
  el.classList.add('just-changed');
}
Enter fullscreen mode Exit fullscreen mode

Two details are worth keeping:

  • The reflow. Removing and re-adding the class in the same frame does not restart a CSS animation — the browser coalesces the two mutations. Reading offsetWidth between them forces a style recalculation, which is the cheapest way to make the animation replay on every click.
  • The reduced-motion fallback. Skipping the animation entirely would remove the information, not just the motion, so the media query keeps a static colour instead:
@media (prefers-reduced-motion: reduce) {
    .form-control.just-changed {
        animation: none;
        background-color: #fff3e2;
        border-color: var(--dy-orange);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Passenger card after Randomise: the fields whose value changed carry an orange wash, the untouched ones stay plain

The count of changed controls is worth reporting too — it turns "did that do anything?" into a number:

analytics.action('randomise', {
  style: state.style,
  format: state.format,
  legs: legs,
  changed: changed
});
Enter fullscreen mode Exit fullscreen mode

Step 7: Prove the payload by parsing it back

Testing a scanner needs a generator you can verify. The most direct check is to parse your own output with the same code the scanner will use: write the decoder alongside the encoder, and treat the parse result as the test oracle rather than restating the form.

The expected scanner output table: every item, its byte offset, raw spelling and decoded value

Because the two directions share one field table, the table above is read back from the bytes. That also makes a round-trip test possible: parse a payload, rebuild the record, re-encode, and require the bytes to come back identical.

function selfTest() {
  var results = SELF_TEST_VECTORS.map(function (vector) {
    var decoded = decode(vector.payload);
    var problems = [];
    if (!decoded.ok) {
      problems.push(decoded.error);
    } else {
      /* The round trip has to hold for the examples too: parse, rebuild the
         record, re-encode, and the bytes must come back identical. */
      try {
        var reencoded = encode(toRecord(decoded));
        if (reencoded !== vector.payload) {
          problems.push('re-encode differs from the original payload');
        }
      } catch (error) {
        problems.push('re-encode failed: ' + error.message);
      }
    }
    return { name: vector.name, passed: problems.length === 0, problems: problems };
  });
  return {
    passed: results.filter(function (r) { return r.passed; }).length,
    failed: results.filter(function (r) { return !r.passed; }).length,
    results: results
  };
}
Enter fullscreen mode Exit fullscreen mode

Running that against the official Resolution 792 examples is what turns "it looks right" into a check. On page load the demo logs:

[boarding-pass] BCBP codec self-test: 4/4 IATA Resolution 792 examples round-trip
Enter fullscreen mode Exit fullscreen mode

The four vectors cover the awkward cases rather than the happy path: a single leg with no conditional data (where item 6 is 00 and nothing follows it — not even the item 17 size field), a single leg with unique, repeated and security data, a two-leg pass with a different PNR per leg, and a version 6 pass whose size fields are present but zero.

The structural checks report the same thing to the user:

The structural checks, all passing, with the evidence for each

Step 8: Sign the security section automatically

The final block of the variable field is the security section — items 25–30, written once after the last leg. ^ marks the beginning (item 25), one character carries the type of security data (item 28), two hexadecimal characters carry the byte length of item 30 (item 29), and everything after that is the data itself:

...0025 106>60000^158iTRJaYu4CyZpYnRlKocV5gzDJEwsyeDmxpRo4d2gUl99ymfpHkIKySSyj8H1gv0RqVtBV5PzRhMP9XuYGILrZA==
                  ^ item 25: '^' marks the beginning of the security section
                   1 item 28: type of security data
                    58 item 29: two hex digits — 58 = 88 bytes of item 30
                      iTRJ… item 30: the signature itself, 88 base64 characters
Enter fullscreen mode Exit fullscreen mode

Signing it raises one structural problem: item 30 is inside the payload it would sign, and a signature cannot cover itself. The fix is to sign everything up to the end of the last leg, then append items 25–30 afterwards — and for the verifier to strip that same section off before checking, so both sides always compare the same bytes. One helper does the trimming for both directions:

/** The payload with its security section removed — the bytes that get signed. */
function securityBase(payload) {
  var text = str(payload);
  var parsed = decode(text);
  if (!parsed.ok || !parsed.security) return text;
  /* decoded.security.offset points at the type character (item 28); the '^'
     marking item 25 sits immediately before it. */
  return text.slice(0, parsed.security.offset - 1);
}
Enter fullscreen mode Exit fullscreen mode

The second problem is encoding. ECDSA P-256 returns a raw 64-byte r‖s signature; hex-encoded that is 128 characters, and validate() puts a 100-character ceiling on item 30 — a hex signature is rejected before any key is involved. Base64 of the same bytes is 88 characters, comfortably inside the limit, so that is what item 30 carries. The signing itself is WebCrypto:

function signSecurityData(payload, privateKeyBase64) {
  var subtle = subtleCrypto();
  if (!subtle) return Promise.resolve(null);
  var data = new TextEncoder().encode(securityBase(payload));
  return importSigningKey('pkcs8', privateKeyBase64, ['sign'])
    .then(function (key) { return subtle.sign(SIGN_PARAMS, key, data); })
    .then(function (signature) { return base64FromBytes(new Uint8Array(signature)); })
    .catch(function () { return null; });
}
Enter fullscreen mode Exit fullscreen mode

generate() then signs on every pass: encode the record with security: null, sign those bytes with the demo private key, and write the result back into the two security fields:

function autoSign(record) {
  var base;
  try {
    base = window.Bcbp.encode(Object.assign({}, record, { security: null }));
  } catch (error) {
    return Promise.resolve(null); /* renderPayload() reports the real error */
  }
  return window.Bcbp.signSecurityData(base, DEMO_PRIVATE_KEY).then(function (signature) {
    if (!signature) return null;
    els.security_type.value = SECURITY_TYPE;
    els.security_data.value = signature;
    return signature;
  });
}
Enter fullscreen mode Exit fullscreen mode

The demo private key ships with the page, which is the one thing a production issuer must never do: that key belongs on the issuing server, only the signature travels, and readers get the public half — exactly what the companion scanner verifies against. WebCrypto also needs a secure context, so the page must be served from http://localhost or https; when it is not, the pass is left unsigned as typed and the hint under the field says so.

Because signing runs on every change, a keystroke in either security field would be overwritten immediately — so that keystroke latches manual mode instead: the value stays exactly as typed and nothing re-signs until Re-sign or Randomise is pressed.

/* Item 30 is machine-filled, so a keystroke in either security field is a
   deliberate hand edit: latch manual mode, keep the value as typed (even
   if it is empty) and stop re-signing until Re-sign or Randomise. */
function securityEdited() {
  state.securityManual = true;
  schedule();
}
els.security_type.addEventListener('input', securityEdited);
els.security_type.addEventListener('change', securityEdited);
els.security_data.addEventListener('input', securityEdited);
els.security_data.addEventListener('change', securityEdited);
Enter fullscreen mode Exit fullscreen mode

After each render the page verifies its own output against the public key and reports the verdict in the hint: green for a signature that matches, red for one that does not (calling out the hand edit), plain when items 25–30 are absent. Re-signing without changing anything still produces a different value — ECDSA draws a fresh random nonce for every signature — but verification always strips back to the same base, so every one of those signatures checks out.

Source Code

Get the complete sample project source code on GitHub

Top comments (0)