How to Parse BER-TLV Data in JavaScript

How to Parse TLVs in JavaScript

Smart-card data very often reaches your code as BER-TLV: a run of bytes in which every field announces what it is, how long it is, and then carries its value. This page explains exactly how those bytes are encoded and gives you a small, dependency-free JavaScript parser you can paste into a Node.js or browser project. The parser is covered by a test suite that includes random round-trip and fuzz tests, and it rejects malformed input instead of guessing.

Scope. The parser implements the definite-length tag and length rules of ITU-T X.690 (Basic Encoding Rules), the same rules Java Card’s own BER-TLV API is built on. It is not a general ASN.1 decoder, and it is not meant for every protocol that happens to call its records “TLV”.

EMV note. Payment specifications such as EMV define their own tag dictionaries and may add rules (allowed tag sizes, maximum lengths, padding) on top of BER. Those documents are published by EMVCo under its own terms of use and are not reproduced or paraphrased here. Check your tags and limits against the specification you implement; the parser below deliberately stays generic so that it does not get in the way.

TLV in one minute

TLV stands for Tag, Length, Value. A reader moves through the bytes in that order:

  • Tag – identifies the field (which data element this is).
  • Length – how many bytes of value follow.
  • Value – the field’s data, exactly that many bytes.

In the simplest possible TLV scheme every tag and every length is one byte, so 01 03 41 42 43 means “field 1, three bytes, ABC“. Because each record states its own length, a reader can skip fields it does not understand and still land on the next one. That property is the whole point of TLV.

BER-TLV keeps the idea but does not keep the one-byte assumption. A BER tag can span several bytes, a length can be one byte or several, and a value can itself contain further TLV objects. A parser that hard-codes “tag = 1 byte, length = 1 byte” works on toy examples and fails on real card data.

TLV vs BER-TLV

“TLV” is a pattern, not a standard. Many protocols use it, each with its own rules. BER-TLV is one specific, fully defined encoding.

Feature “TLV” in general BER-TLV (X.690)
Tag size Fixed by each protocol (often 1 or 2 bytes) 1 byte, or more when the low five bits of the first byte are all ones
Length size Fixed by each protocol Short form (1 byte, 0–127) or long form (a count byte plus 1–126 length bytes)
Nested values Only if the protocol defines them Yes: a bit in the tag marks the value as a sequence of TLV objects
Tag meaning Defined by the protocol Class and number are encoded in the tag; the meaning comes from the application (EMV, ISO 7816, X.509 …)
Where you meet it Vendor protocols, network options, file formats Smart-card data (Java Card includes a BER-TLV API), ASN.1 data such as X.509 certificates (DER, a stricter subset of BER)

The practical consequence: before reusing a “TLV parser”, check which TLV it parses. The code on this page is for BER-TLV. Protocols that call their records “TLV” but use, for example, a fixed two-byte length need their own few lines of code.

Anatomy of a BER-TLV object

Take the nine bytes 9F 02 06 00 00 00 00 15 00:

9F 02 | 06     | 00 00 00 00 15 00
tag   | length | value (6 bytes)

9F = 1001 1111
     10        class: context-specific
       0       primitive (the value is plain data, not more TLVs)
        11111  "tag number continues in the next byte"
02 = 0000 0010
     0         last tag byte
      0000010  tag number 2

What 9F02 means is decided by the application specification that defines it, not by BER. The parser does not need to know. It only needs the encoding rules, which are the same for every tag.

Multi-byte BER tags

The first tag byte is split into three fields (X.690 §8.1.2):

Bits Meaning
8–7 Class: 00 universal, 01 application, 10 context-specific, 11 private
6 0 primitive, 1 constructed (the value is a series of TLV objects)
5–1 Tag number 0–30, or 11111 meaning “the tag continues”

When bits 5–1 are 11111, the tag goes on. Each following byte contributes seven bits of the tag number, and its top bit says whether yet another byte follows (1) or this is the last one (0). So DF 81 01 is a three-byte tag: 81 has its top bit set, 01 does not, and the tag number is 1 × 128 + 1 = 129.

One detail matters in practice. X.690 says tag numbers from 0 to 30 must use the single-byte form, yet a tag written as 9F02 encodes tag number 2 in the two-byte form. Data you receive from cards and terminals will not always follow the strict rule, so a parser for that data should treat the tag bytes as the identity of the field and should not “normalise” or reject them. That is why the parser below keys every object by the hex string of its tag bytes ("9F02") and reports the class, constructed flag and number only as extra information.

Here is how the parser decodes a few common tags:

import { parseBerTlv } from './ber-tlv.mjs';

// Parse a zero-length object for each tag just to look at how the tag is decoded.
for (const tag of ['84', '9F02', '5F2A', 'BF0C', 'DF8101']) {
  const [item] = parseBerTlv(tag + '00');
  console.log(tag.padEnd(6), item.tagClass.padEnd(16), item.constructed ? 'constructed' : 'primitive  ', 'tag number', item.tagNumber);
}
84     context-specific primitive   tag number 4
9F02   context-specific primitive   tag number 2
5F2A   application      primitive   tag number 42
BF0C   context-specific constructed tag number 12
DF8101 private          primitive   tag number 129

BER length encoding

The first length byte decides the form (X.690 §8.1.3):

First length byte Form Meaning
00–7F Short The byte is the length: 0–127
81 xx Long One length byte follows: 0–255
82 xx xx Long Two length bytes follow, most significant first: 0–65,535
83, 84 Long Three or four length bytes follow
80 Indefinite No length; the value ends with two zero bytes. Rejected by this parser
FF Reserved Never valid

Three rules follow from this, and the parser enforces all of them:

  • Read the count correctly. In the long form, the low seven bits of the first byte give the number of length bytes. Masking with five bits happens to work for small lengths and fails on anything else.
  • Check bounds before reading. Every length byte and every value byte must exist inside the data you were given. A length that points past the end is an error, not a shorter value.
  • Accept what BER allows. BER lets the sender use the long form even for a small length, so 81 05 is a valid way to say 5. (DER, the stricter subset used for certificates, forbids that; card data uses BER.)

Why reject the indefinite form? It is only legal for constructed values, its end is found by scanning for a terminator rather than by counting, and the card data this parser is for uses definite lengths. Refusing it with a clear error is safer than half-supporting it. The parser also stops at four length bytes (values up to 4 GiB − 1), which keeps every length exact as a JavaScript number and is far beyond anything a smart card returns.

Primitive vs constructed objects

Bit 6 of the first tag byte says whether the value is plain data (primitive) or a series of complete TLV objects (constructed). 6F, 70, 77, A5, 61 and BF0C all have that bit set (look at bit 6 of their first byte), so data built from them is a tree rather than a flat list.

Parsing a constructed value means parsing its value bytes again as TLV, but with two safeguards:

  • Stay inside the parent. The children of an object must end exactly where the parent’s value ends. If a child’s length runs past that boundary, the data is malformed. Re-parsing the rest of the buffer instead silently mixes up siblings and parents.
  • Limit depth. Real card data nests a handful of levels. A depth limit (10 by default here) stops hostile or corrupt input from driving recursion arbitrarily deep.

Do not decide “constructed” from a list of known tags or from a tag prefix. The bit is in every tag, it is authoritative, and it works for proprietary tags you have never seen.

A safe JavaScript BER-TLV parser

Save this as ber-tlv.mjs. It is an ES module with no dependencies and uses only long-established JavaScript features, so the same file runs in Node.js and in the browser.

// ber-tlv.mjs - parse definite-length BER-TLV data (ITU-T X.690 tag and length rules),
// the tag-length-value encoding found in smart-card and other ASN.1 BER data.
// No dependencies: the same file runs in Node.js and in the browser.

export class TlvError extends Error {
  constructor(message, offset) {
    super(`${message} (at byte offset ${offset})`);
    this.name = 'TlvError';
    this.offset = offset;
  }
}

// "9F02 06 000000001500" -> Uint8Array. Whitespace is ignored; anything else must be hex.
export function hexToBytes(hex) {
  const digits = hex.replace(/\s+/g, '');
  const bad = digits.search(/[^0-9a-fA-F]/);
  if (bad !== -1) throw new TlvError(`invalid hex character '${digits[bad]}'`, Math.floor(bad / 2));
  if (digits.length % 2 !== 0) throw new TlvError('hex string has an odd number of digits', digits.length >> 1);
  const bytes = new Uint8Array(digits.length / 2);
  for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(digits.slice(2 * i, 2 * i + 2), 16);
  return bytes;
}

export function bytesToHex(bytes) {
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
}

const CLASS_NAMES = ['universal', 'application', 'context-specific', 'private'];
const MAX_TAG_OCTETS = 4;    // parser limit; real-world tags are rarely longer than 3 octets
const MAX_LENGTH_OCTETS = 4; // parser limit; lengths up to 4 GiB - 1 fit safely in a JS number

// Identifier octets: class (bits 8-7), constructed flag (bit 6), tag number (bits 5-1,
// or 11111 followed by base-128 octets whose top bit means "another octet follows").
function readTag(bytes, pos, end) {
  const start = pos;
  const first = bytes[pos++];
  let tagNumber = first & 0x1f;
  if (tagNumber === 0x1f) {
    tagNumber = 0;
    let octet;
    do {
      if (pos >= end) throw new TlvError('tag is cut off', start);
      if (pos - start >= MAX_TAG_OCTETS) throw new TlvError(`tag longer than ${MAX_TAG_OCTETS} octets`, start);
      octet = bytes[pos++];
      tagNumber = tagNumber * 128 + (octet & 0x7f);
    } while (octet & 0x80);
  }
  return {
    tag: bytesToHex(bytes.subarray(start, pos)),
    tagClass: CLASS_NAMES[first >> 6],
    constructed: (first & 0x20) !== 0,
    tagNumber,
    next: pos,
  };
}

// Length octets: short form 0x00-0x7F is the length itself; 0x81-0x84 say how many
// big-endian length octets follow. 0x80 (indefinite) and longer fields are rejected.
function readLength(bytes, pos, end) {
  if (pos >= end) throw new TlvError('length is missing', pos);
  const first = bytes[pos++];
  if (first < 0x80) return { length: first, next: pos };
  const count = first & 0x7f;
  if (count === 0) throw new TlvError('indefinite length (0x80) is not supported', pos - 1);
  if (count > MAX_LENGTH_OCTETS) throw new TlvError(`length field of ${count} octets is not supported`, pos - 1);
  if (pos + count > end) throw new TlvError('length field is cut off', pos - 1);
  let length = 0;
  for (let i = 0; i < count; i++) length = length * 256 + bytes[pos++];
  return { length, next: pos };
}

function parseRange(bytes, pos, end, depth, opts) {
  const items = [];
  while (pos < end) {
    if (opts.skipZeroPadding && bytes[pos] === 0x00) { pos++; continue; }
    const offset = pos;
    const t = readTag(bytes, pos, end);
    const l = readLength(bytes, t.next, end);
    const valueStart = l.next;
    const valueEnd = valueStart + l.length;
    if (valueEnd > end) {
      throw new TlvError(`tag ${t.tag} declares ${l.length} value bytes but only ${end - valueStart} remain`, offset);
    }
    const item = {
      tag: t.tag,
      tagClass: t.tagClass,
      constructed: t.constructed,
      tagNumber: t.tagNumber,
      offset,
      headerLength: valueStart - offset,
      length: l.length,
      value: bytes.subarray(valueStart, valueEnd),
    };
    if (t.constructed) {
      if (depth >= opts.maxDepth) throw new TlvError(`nesting deeper than ${opts.maxDepth} levels`, offset);
      item.children = parseRange(bytes, valueStart, valueEnd, depth + 1, opts);
    }
    items.push(item);
    pos = valueEnd;
  }
  return items;
}

// Parse a hex string or Uint8Array into an array of TLV items. Constructed items get a
// `children` array parsed strictly inside their own value bytes. Throws TlvError on bad input.
export function parseBerTlv(input, { maxDepth = 10, skipZeroPadding = false } = {}) {
  const bytes = typeof input === 'string' ? hexToBytes(input) : input;
  if (!(bytes instanceof Uint8Array)) throw new TypeError('expected a hex string or a Uint8Array');
  return parseRange(bytes, 0, bytes.length, 0, { maxDepth, skipZeroPadding });
}

// Depth-first search for the first item with this tag, e.g. findTag(items, '9F02').
export function findTag(items, tag) {
  const wanted = tag.toUpperCase();
  for (const item of items) {
    if (item.tag === wanted) return item;
    const hit = item.children && findTag(item.children, wanted);
    if (hit) return hit;
  }
  return undefined;
}

What each item in the result contains:

Field Meaning
tag Tag bytes as upper-case hex, e.g. "9F02" – use this to identify the field
tagClass, constructed, tagNumber The decoded parts of the tag
offset Position of the first tag byte in the original input, also for nested items
headerLength Number of tag and length bytes, so the value starts at offset + headerLength
length, value Value length and a Uint8Array view of the value (no copy)
children Parsed items inside a constructed value (constructed items only)

Design choices worth knowing about:

  • No tag dictionary. Tags are recognised by the encoding rules alone, so proprietary and unknown tags parse like any other. Naming tags (“9F02 is the amount”) is a separate lookup you can add on top.
  • Errors, not guesses. Any input that breaks the rules throws a TlvError with the byte offset where the problem starts. For card data a loud failure is better than a plausible-looking wrong value.
  • Repeated tags are kept. The result is a list, not an object keyed by tag, so two objects with the same tag both survive, in order. findTag returns the first match in depth-first order.
  • Padding is opt-in. If the data you receive may contain 00 filler bytes between objects, pass { skipZeroPadding: true } to skip them. By default a 00 byte is parsed as a tag, because in plain BER it is one.

Examples

1. A flat list of primitive objects

Three primitive objects one after another, each with a two-byte tag and a short-form length:

import { parseBerTlv, bytesToHex } from './ber-tlv.mjs';

// Three primitive objects back to back: two-byte tags, short-form lengths.
const items = parseBerTlv('9F02 06 000000001500  5F2A 02 0356  9F1A 02 0356');

for (const item of items) {
  console.log(`offset ${item.offset}: tag ${item.tag}, length ${item.length}, value ${bytesToHex(item.value)}`);
}
offset 0: tag 9F02, length 6, value 000000001500
offset 9: tag 5F2A, length 2, value 0356
offset 14: tag 9F1A, length 2, value 0356

The offsets let you point at the exact byte when something in a log looks wrong: 5F2A starts at byte 9 because 9F02 used 3 header bytes and 6 value bytes.

2. A nested structure, with the status bytes removed

A four-level tree: a constructed 6F holds a primitive 84 (here the ASCII text TEST.SELECT.01) and a constructed A5, which holds BF0C, then 61, then two primitive objects. The input ends the way a card response does, with two status bytes SW1 SW2 (90 00 means “no error”). They are not TLV and must be removed before parsing.

import { parseBerTlv, findTag, bytesToHex, hexToBytes } from './ber-tlv.mjs';

// A card response: data field followed by the two status bytes SW1 SW2.
// (All values are made-up placeholders, not real application data.)
const response = hexToBytes(
  '6F 23 84 0E 544553542E53454C4543542E3031' +
  '      A5 11 BF0C 0E 61 0C 4F 07 A0112233445566 87 01 01' +
  ' 90 00'
);

const sw = bytesToHex(response.subarray(-2));
if (sw !== '9000') throw new Error(`card returned status ${sw}`);
const items = parseBerTlv(response.subarray(0, -2)); // never feed SW1 SW2 to the parser

function print(list, indent = '') {
  for (const item of list) {
    const shown = item.constructed ? '' : ' = ' + bytesToHex(item.value);
    console.log(`${indent}${item.tag} (offset ${item.offset}, length ${item.length})${shown}`);
    if (item.children) print(item.children, indent + '  ');
  }
}
print(items);

const name = findTag(items, '84');
console.log('84 as text:', new TextDecoder().decode(name.value));
6F (offset 0, length 35)
  84 (offset 2, length 14) = 544553542E53454C4543542E3031
  A5 (offset 18, length 17)
    BF0C (offset 20, length 14)
      61 (offset 23, length 12)
        4F (offset 25, length 7) = A0112233445566
        87 (offset 34, length 1) = 01
84 as text: TEST.SELECT.01

Every child’s offset points into the same buffer as its parent, and every child ends inside its parent. That is the property that makes it safe to walk the tree.

3. Malformed and truncated input

import { parseBerTlv } from './ber-tlv.mjs';

const broken = {
  'value cut short': '9F02 06 00000000',
  'child overruns parent': '70 04 5A 05 AABB 9F02 00',
  'length field cut short': '5A 82 01',
  'indefinite length': '70 80 5A 01 AA 00 00',
  'not hex': '9F02 02 12G4',
};

for (const [label, hex] of Object.entries(broken)) {
  try {
    parseBerTlv(hex);
    console.log(`${label}: parsed (unexpected)`);
  } catch (err) {
    console.log(`${label}: ${err.name}: ${err.message}`);
  }
}
value cut short: TlvError: tag 9F02 declares 6 value bytes but only 4 remain (at byte offset 0)
child overruns parent: TlvError: tag 5A declares 5 value bytes but only 2 remain (at byte offset 2)
length field cut short: TlvError: length field is cut off (at byte offset 1)
indefinite length: TlvError: indefinite length (0x80) is not supported (at byte offset 1)
not hex: TlvError: invalid hex character 'G' (at byte offset 4)

Each error says what is wrong and where. In the second case the outer 70 claims four value bytes and the inner 5A claims five: the parser stops at the parent boundary instead of reading on into the next object.

Using it with real card and terminal data

  • Strip the status word first. A card response is the data field followed by the two status bytes SW1 SW2 (90 00 = no error). Check them, then parse only the data field.
  • Bytes in, not text. If your reader library gives you a Buffer or ArrayBuffer, wrap it as new Uint8Array(…) and pass it directly. Use the hex form for logs and tests.
  • Lengths are big-endian. Multi-byte lengths are stored most significant byte first regardless of the CPU you run on. If byte order is unfamiliar territory, see big endian vs little endian byte order.
  • Parse first, interpret second. Keep the generic parser free of application knowledge. Decode amounts, dates and text in a second step that looks up the tag; that way one parser serves every BER-TLV format you handle, including proprietary ones.
  • Treat values as untrusted. The parser guarantees structure, not meaning. A value of the right length can still contain nonsense, so validate fields before you rely on them.

How the parser was tested

The module above is the exact file that was tested; the examples on this page were run against it and their output pasted unchanged. The suite uses Node’s built-in test runner (node --test) and checks:

  • one-, two- and three-byte tags; class, constructed bit and tag number;
  • zero-length values, short form up to 127, long forms 81 and 82, and a non-minimal 81 05;
  • adjacent objects, nested constructed objects with absolute offsets, empty constructed values, repeated and unknown tags, hex and Uint8Array input;
  • invalid hex, an odd number of hex digits, truncated tags, missing and truncated lengths, a value longer than the remaining data, a child that overruns its parent, indefinite length, over-long length fields, and excessive nesting;
  • a property test that builds 5,000 random well-formed trees, encodes them and checks that parsing returns exactly the same tree;
  • a fuzz test that feeds 200,000 random byte strings and requires either a result whose every child lies inside its parent or a TlvError, never any other exception;
  • a truncation test that cuts 2,000 random valid encodings at every possible position and requires an error unless the cut falls exactly between two top-level objects.

As a check that the tests catch real mistakes, four deliberately broken versions of the parser were run against the suite (a five-bit length mask, no parent boundary check, recursing only for tags that start with FF, and accepting indefinite length). The suite failed all four.

Common mistakes in hand-written TLV parsers

  • Assuming one-byte tags and one-byte lengths.
  • Masking the long-form length count with 0x1F instead of 0x7F.
  • Recognising tags by looking them up in a list, so any tag missing from the list breaks the parse of everything after it.
  • Deciding which objects are constructed from a hard-coded list or prefix instead of bit 6 of the first tag byte.
  • Flattening the tree into one object keyed by tag, which loses nesting and silently overwrites repeated tags.
  • Returning a short value when the declared length runs past the end of the data, instead of reporting truncation.
  • Feeding SW1 SW2 into the parser together with the data field.
  • Copying code from a web page that has turned straight quotes into curly ones. If pasted code fails with “invalid or unexpected token”, check the quotes first.

Frequently asked questions

Is BER-TLV the same as ASN.1?

No. ASN.1 is a language for describing data structures; BER is one set of rules for turning ASN.1 values into bytes. Card data typically uses BER’s tag and length layout without a full ASN.1 schema, which is why a small parser like this one is enough to walk it.

Can this parser read X.509 certificates?

It can walk their structure: RFC 5280 encodes the signed certificate data with DER, which is a stricter form of BER. It does not check DER’s extra rules (such as shortest-form lengths) and does not decode ASN.1 types such as integers, object identifiers or dates. For certificates, use a dedicated library.

Why does the parser not support indefinite length?

The indefinite form is only allowed for constructed values and needs a different reading strategy (scanning for an end marker); the card data this parser targets uses definite lengths. Rejecting it with a clear error is safer than partial support. If you need it, add it in readLength and parseRange and extend the tests first.

How do I get human-readable names for tags?

Build a small lookup table, from the specification that defines your tags, for the tags your application actually uses, and apply it after parsing. Keeping names out of the parser means unknown tags never break parsing.

Does it work in the browser?

Yes. It uses only Uint8Array, string methods and Error. Load it with <script type="module"> or through your bundler.

Related reading on Ambimat

Sources

  • ITU-T Recommendation X.690 (02/2021) | ISO/IEC 8825-1:2021, ASN.1 encoding rules: Specification of Basic Encoding Rules (BER), Canonical Encoding Rules (CER) and Distinguished Encoding Rules (DER) – §8.1.2 identifier (tag) octets, §8.1.3 length octets, §10.1 DER length form: itu.int/rec/T-REC-X.690
  • Oracle, Java Card API 3.2, package javacardx.framework.tlv (BER-TLV classes, based on ISO/IEC 8825-1): docs.oracle.com
  • Oracle, Java Card API 3.2, interface javacard.framework.ISO7816 (SW_NO_ERROR = 0x9000) and class APDU (response status bytes): ISO7816, APDU
  • IETF RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile, §4.1 (DER encoding of certificate fields): rfc-editor.org/rfc/rfc5280
  • Node.js documentation, Test runner (node:test): nodejs.org/api/test.html

Written and tested by the Ambimat engineering team. The parser and examples were run on Node.js 25.8.2.