How to Generate an AAMVA Driver's License Barcode in JavaScript for Scanner Testing
If you are building a driver’s license scanner, the hardest part is finding documents to test it with. Real licenses are personal data, so you cannot put them in a test suite or commit them to a repository. And the sample images you can find online cover one or two jurisdictions on a single version of the AAMVA standard — out of 71 jurisdictions and three versions.
So the differences between cards go untested: a card from another jurisdiction has a different field set and jurisdiction code, a v9 card carries different mandated data elements than a v10 card, and an ID card uses a different subfile type than a DL card. One sample image is evidence for one combination out of hundreds, and the gaps only surface as missing or wrong fields once real cards arrive.
You can generate that test data yourself, because the AAMVA barcode is plaintext. It is a byte layout, not a signed blob, so a generator needs no keys, no cards and no special encoder: assemble the payload, encode it as a PDF417 symbol with bwip-js, and draw it on a canvas card. This article does that for all 71 jurisdictions AAMVA assigns an Issuer Identification Number to — 50 US states, the District of Columbia, 5 US territories, 13 Canadian provinces and territories, and 2 Mexican states — and then decodes the result with Dynamsoft Barcode Reader to prove the round trip works. The payoff is a card you can generate on demand, containing no personal information, for any of 71 jurisdictions × 3 versions of the standard × 2 document types — and re-create whenever a test starts failing.
What you’ll build: a browser-side generator that takes a jurisdiction and a set of cardholder fields, assembles a specification-compliant AAMVA payload, renders it as a PDF417 symbol on a printable card face and back, and exports the whole sheet as a PNG. Then you’ll feed that PNG back into a scanner to confirm the fields survive the trip.
Online Demo
The finished generator runs at codepool/demos/driver-license-generator — no install and no license key, and nothing you type leaves the browser. Read the cards it produces with the matching driver’s license scanner demo, which also runs entirely in the browser.
Key Takeaways
- An AAMVA barcode is plaintext — no signature, no encryption — so generating one only requires getting the byte layout right.
- The payload is a 21-character header, a 10-character subfile designator (
type + offset + length) and thenCODE + valuedata elements joined by the data element separator (\n), terminated by the segment terminator (\r). - The subfile offset is computed, not guessed:
header.length + 10 × entryCount. Getting it wrong is the most common reason a syntactically valid payload is rejected. - bwip-js encodes PDF417 with
bcid: 'pdf417'. Render at a highscaleso each module ends up several pixels wide in the exported PNG — that is what keeps the barcode readable after download and re-upload. - Dynamsoft’s
AAMVA_DL_IDcode parser accepts all 71 jurisdictions. A verified pass over 71 jurisdictions × 3 AAMVA versions × 2 subfile types showed 426/426 payloads encoded, decoded and parsed with every field preserved. - A barcode that parses correctly is not a valid license. See What This Does and Does Not Prove.
Common Developer Questions
How do I create a driver’s license barcode for testing in JavaScript?
Assemble the AAMVA payload as a string, then encode it as PDF417 with bwipjs.toCanvas(canvas, { bcid: 'pdf417', text: payload, scale: 8, rowmult: 3 }). No keys or server are involved: the AAMVA format is plaintext, so any encoder can produce a symbol that a compliant reader will decode. Restrict the payload to a test jurisdiction and keep the SPECIMEN marking on the rendered card so the output cannot be mistaken for a genuine document.
What is the structure of an AAMVA driver’s license barcode payload?
The payload has three parts. First a 21-character header: the compliance indicator @, the data element separator (\n), the record separator (\x1e), the segment terminator (\r), the literal file type ANSI , a six-digit Issuer Identification Number, the two-digit AAMVA version, the two-digit jurisdiction version and the two-digit number of subfiles. Second, one 10-character subfile designator per subfile, made of the two-character subfile type (DL or ID) plus a four-digit offset and a four-digit length. Third, the subfile itself: its two-character type, then the data elements.
Why does my AAMVA payload get rejected even though the header looks correct?
The subfile offset is usually the culprit. It is the byte position of the subfile measured from the very first character of the payload, so with one subfile it is 21 + 10 = 31, and the length must include the two-character subfile type and the trailing segment terminator. A secondary cause is date format: AAMVA uses MMDDYYYY, so 07/05/1979 becomes 07051979.
How do I test a driver’s license scanner without real licenses?
Generate synthetic cards for each jurisdiction and AAMVA version you care about, then decode them with the same SDK the scanner uses. Because the payload is plaintext, you can assert exact field equality between what you encoded and what the scanner returned — a stronger regression signal than eyeballing a photo of a real card. Keep the generated cards marked as specimens and never treat a successful parse as evidence that a document is genuine.
Prerequisites
- A browser with canvas support. The generator needs no SDK and no license key.
- A Dynamsoft Capture Vision license if you want to decode the generated card yourself. Get a 30-day free trial license.
Step 1: Lay Out the AAMVA Payload
Everything rests on getting the byte layout right, so start from the specification rather than from a sample card. The header is fixed-width and position-dependent:
| Field | Size | Example | Notes |
|---|---|---|---|
| Compliance indicator | 1 | @ |
Always @ |
| Data element separator | 1 | \n |
Terminates each data element |
| Record separator | 1 | \x1e |
|
| Segment terminator | 1 | \r |
Ends each subfile |
| File type | 5 | ANSI |
Note the trailing space |
| Issuer Identification Number | 6 | 636014 |
California |
| AAMVA version number | 2 | 10 |
v8 = 2013, v9 = 2016, v10 = 2020 |
| Jurisdiction version number | 2 | 01 |
Per-jurisdiction revision |
| Number of entries | 2 | 01 |
Number of subfiles that follow |
Each subfile designator is ten characters — the subfile type, then a four-digit offset and a four-digit length, both zero-padded. Build the payload only after you know the header length, because the offset depends on it:
var LF = '\n';
var RS = '\x1e';
var CR = '\r';
function pad(value, length) {
var s = String(value);
while (s.length < length) s = '0' + s;
return s;
}
function buildPayload(sample, aamvaVersion, jurisdictionVersion) {
var elements = [];
function add(code, value) {
if (value === undefined || value === null || value === '') return;
elements.push(code + value);
}
add('DAQ', sample.licenceNumber); // licence number
add('DCS', sample.lastName); // family name
add('DDE', 'N'); // family name truncation
add('DAC', sample.firstName); // first name
add('DDF', 'N');
add('DAD', sample.middleName);
add('DDG', 'N');
add('DCA', sample.vehicleClass); // jurisdiction vehicle class
add('DCB', sample.restrictions);
add('DCD', sample.endorsements);
add('DBD', mmddyyyy(sample.issueDate)); // issue date
add('DBB', mmddyyyy(sample.birthDate)); // date of birth
add('DBA', mmddyyyy(sample.expiryDate));// expiry date
add('DBC', sample.sexCode); // 1 = male, 2 = female
add('DAU', sample.height); // e.g. '068 in'
add('DAY', sample.eyeColor);
add('DAZ', sample.hairColor);
add('DAG', sample.street);
add('DAI', sample.city);
add('DAJ', sample.jurisdictionCode);
add('DAK', sample.postal);
add('DCF', sample.documentDiscriminator);
add('DCG', sample.countryCode); // USA / CAN / MEX
add('DDA', 'F'); // compliance type
add('DDB', mmddyyyy(sample.issueDate)); // card revision date
add('DDD', '1');
add('DAW', sample.weightLbs);
var subfile = sample.cardType + elements.join(LF) + CR; // 'DL' or 'ID'
var entryCount = 1;
var header = '@' + LF + RS + CR + 'ANSI '
+ sample.iin + aamvaVersion + jurisdictionVersion + pad(entryCount, 2);
// The subfile starts after the header and all designators.
var offset = header.length + 10 * entryCount;
var designator = sample.cardType + pad(offset, 4) + pad(subfile.length, 4);
return header + designator + subfile;
}
function mmddyyyy(date) {
return pad(date.getMonth() + 1, 2) + pad(date.getDate(), 2) + date.getFullYear();
}
With a one-subfile payload the offset therefore always evaluates to 31, and subfile.length includes the leading DL and the trailing \r. Add an assertion while you develop — off-by-one errors here produce a payload that looks plausible but decodes to nothing.
Step 2: Cover Every AAMVA Jurisdiction
A generator is only useful if it can produce the jurisdictions you need to test. AAMVA publishes the Issuer Identification Numbers it assigns, which makes the table finite and checkable: 71 entries covering the US, Canada and two Mexican states.
// code, name, iin, country, sample city, postal, licence-number pattern
var JURISDICTIONS = [
['CA', 'California', '636014', 'USA', 'Sacramento', '95814', 'A#######'],
['TX', 'Texas', '636015', 'USA', 'Austin', '78701', '########'],
['NY', 'New York', '636001', 'USA', 'Albany', '12207', '#########'],
['ON', 'Ontario', '636012', 'CAN', 'Toronto', 'M5H 2N1', 'A####-#####'],
['BC', 'British Columbia', '636028', 'CAN', 'Victoria', 'V8W 1A1', '########'],
// ... 66 more
];
function randomFromPattern(pattern) {
var out = '';
for (var i = 0; i < pattern.length; i++) {
var c = pattern[i];
if (c === '#') out += Math.floor(Math.random() * 10);
else if (c === 'A') out += String.fromCharCode(65 + Math.floor(Math.random() * 26));
else out += c;
}
return out;
}

Each jurisdiction row carries what makes its cards distinguishable: the IIN that goes in the header, the two-letter code that goes in DAJ, a plausible sample city and postal code, and a licence-number pattern. Patterns are illustrative rather than authoritative — the point is to exercise a decoder, not to reproduce any particular issuing authority’s formatting.
Step 3: Encode the Payload as PDF417
PDF417 is the symbology North American licenses use, and bwip-js renders it directly to a canvas:
await bwipjs.toCanvas(canvas, {
bcid: 'pdf417',
text: payload,
scale: 8, // pixels per module
rowmult: 3, // row height, in modules
eclevel: 5, // Reed-Solomon error correction level
paddingwidth: 4,
paddingheight: 4,
backgroundcolor: 'FFFFFF',
barcolor: '000000'
});
scale is the setting that decides whether the exported image is scannable. A payload of this size lands around 270–293 characters, which PDF417 lays out in roughly 12 columns; at scale: 8 every module is eight pixels wide, so the symbol survives being downscaled onto a card, exported to PNG and re-uploaded. Rendering at scale: 2 or 3 produces a symbol that looks fine on screen and fails to decode once it has been resized.

Step 4: Render a Card to Download
Draw the front and the back on one canvas so a single PNG carries both the human-readable fields and the barcode. Two details are worth copying: render the sheet at two device pixels per logical unit so the modules stay large, and stamp a SPECIMEN watermark so the output cannot be mistaken for a real document.
var DPR = 2;
canvas.width = SHEET_W * DPR;
canvas.height = SHEET_H * DPR;
var ctx = canvas.getContext('2d');
ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // then draw in logical units
function renderSheet(sample, barcodeCanvas) {
ctx.fillStyle = '#f1f5f9';
ctx.fillRect(0, 0, SHEET_W, SHEET_H);
renderFront(ctx, sample); // header band, photo box, fields
renderBack(ctx, sample, barcodeCanvas); // PDF417 plus a magnetic stripe
ctx.save();
ctx.globalAlpha = 0.16;
ctx.translate(SHEET_W / 2, SHEET_H / 2);
ctx.rotate(-Math.PI / 6);
ctx.fillStyle = '#c81e2b';
ctx.font = 'bold 86px Arial';
ctx.textAlign = 'center';
ctx.fillText('SPECIMEN', 0, 0);
ctx.restore();
}

Here is the whole flow in one pass — pick a jurisdiction, randomise the cardholder data, then scroll through what gets generated, front and back:
Step 5: Decode the Generated Card
Now close the loop. The interesting part is how you hand a still image to CaptureVisionRouter. capture() dispatches on the argument type, and the SDK’s own image picker passes a DSImageData built from canvas pixels — not a Blob, HTMLImageElement or canvas. Only that shape decodes reliably:
function imageToDsImageData(img) {
var width = img.naturalWidth;
var height = img.naturalHeight;
if (width > 4000) { // keep huge photos manageable
height = Math.round(height * 4000 / width);
width = 4000;
}
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0, width, height);
var data = ctx.getImageData(0, 0, width, height);
return {
bytes: new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.length),
width: width,
height: height,
stride: 4 * width,
format: 10 // IPF_ABGR_8888 — canvas RGBA order
};
}
var result = await cvRouter.capture(imageToDsImageData(img), 'ReadDenseBarcodes');
// capture() returns barcodes in `items`; only the streaming receiver uses
// `barcodeResultItems`.
var barcodes = (result.items || []).filter(function (item) {
return item.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE;
});
var parsed = await parser.parse(barcodes[0].bytes);
console.log(JSON.parse(parsed.jsonString));
That items versus barcodeResultItems distinction is worth internalising: reading the wrong property makes capture() look broken, returning zero barcodes for every input, when it is working correctly. Log Object.keys(result) before concluding anything.
The quickest way to check a generated card is the driver’s license scanner demo, which accepts drag & drop, a file picker and clipboard paste. Download the generated PNG, paste it into the demo with Ctrl+V, and the fields come back:


Verify the Whole Matrix, Not One Card
One successful scan proves very little. The generator’s own test pass covers every combination it can produce — 71 jurisdictions × 3 AAMVA versions (v8, v9, v10) × 2 subfile types (DL, ID) = 426 payloads — and checks four things per payload:
| Step | Result |
|---|---|
| Header and subfile-designator structure (offset and length maths) | 426 / 426 |
| PDF417 encoding with bwip-js | 426 / 426 |
| PDF417 decoding — decoded text identical to what was encoded | 426 / 426 |
| AAMVA parsing, with licence number, names and city all preserved | 426 / 426 |
Payload lengths land between 269 and 293 characters. A separate end-to-end pass downloads each generated PNG and uploads it to the scanner through the real UI: 79 round trips covering all 71 jurisdictions plus v8/v9 and ID cases at the shortest and longest payload lengths, 27 fields returned each time.
What This Does and Does Not Prove
The AAMVA payload has no signature and no encryption. Anyone who can decode PDF417 can also write it, which is precisely why a generator like this is possible — and why you should be careful what you claim about the result.
- A barcode that parses correctly proves the payload is well-formed. It says nothing about whether a document is genuine.
- A barcode that disagrees with the text printed on the front of a card is a strong tampering signal. That cross-check is the most valuable thing the barcode offers.
- Age checks, onboarding and access decisions should combine barcode parsing with whatever physical and procedural checks your organisation requires. Avoid describing the pipeline as “verifying” or “authenticating” a licence.
That contrast is sharper against formats that are signed. The PDF417 on the back of a South African driving licence, for example, holds 720 RSA-encrypted bytes: readers recover the data with a published public key, and only the issuing authority holds the private key needed to produce one. A parseable South African payload therefore attests to origin in a way an AAMVA payload cannot — and, for the same reason, no generator can create one.
Every card this generator produces is stamped SPECIMEN and filled with randomly generated sample data. It is not a real ID, it is not verifiable against any government registry, and it must not be used to impersonate anyone. Use it to test a decoder.
Common Issues & Edge Cases
pdf417insufficientCapacityor a rendering timeout: the payload is too long for the symbol at the chosen settings. Drop optional elements, or let bwip-js choose the column count instead of pinning it, rather than loweringscale.- The generated barcode decodes on screen but not from the exported PNG:
scaleis too low, or the canvas was resized after rendering. Draw the barcode at its native size into a canvas that is already at final resolution instead of letting CSS scale it. - The scanner returns zero barcodes for a still image: check that you passed a DSImageData
{ bytes, width, height, stride, format }and that you read the barcodes fromresult.items, notresult.barcodeResultItems. Both mistakes fail silently witherrorCode: 0. - Parsing returns an
exceptioninstead of fields: the payload decoded but did not match a known code spec. ConfirmAAMVA_DL_IDhas been loaded withCodeParserModule.loadSpec("AAMVA_DL_ID")before parsing. [-10005]: The specified file could not be foundfromloadSpec: since the 11.4 Barcode Reader line the parser resources ship as one.datafile per spec, soAAMVA_DL_ID_WITH_MAG_STRIPEno longer exists as a separate file — loadAAMVA_DL_IDandSOUTH_AFRICA_DLonly. Magnetic-stripe licences still parse, because the AAMVA spec bundles that definition.- A jurisdiction’s jurisdiction code comes back as a raw abbreviation: Dynamsoft’s AAMVA spec maps US states, DC, US territories and Canadian provinces to names, but not the two Mexican IINs, which are returned as
CUandHL. That is a parser mapping gap, not a generator fault.
Source Code
The complete generator — jurisdiction table, payload builder, canvas rendering and the test harness — lives in the samples repository:
Get the complete sample project source code on GitHub
To read the cards it produces, open the driver’s license scanner demo — no install required — or build the same reader yourself with the driver’s license PDF417 tutorial.