This is the hands-on companion to our guide to application discovery and selection. That page explains the concepts; this one turns them into code you can run, shows the exact APDUs a terminal exchanges with a card, and collects the failures you are most likely to debug: a directory that is not there, an AID that matches twice, a status word your loop did not expect, and a reader that answers with procedure bytes.
Scope. The code is host-side JavaScript that works with any transport that can send one command APDU and return the response (PC/SC, a serial bridge, Android’s IsoDep, a test double). It implements the generic, openly documented parts of selection: SELECT by AID with first and next occurrence, reading a directory of applications, matching against the terminal’s list. It is not a certified payment kernel, and it leaves scheme-specific rules (how to rank priorities, which applications may be selected without asking the cardholder) to the payment specifications that define them.
The flow you are implementing
terminal has: list of AIDs it supports
1. SELECT the directory by name ("2PAY.SYS.DDF01" contactless, "1PAY.SYS.DDF01" contact)
90 00 -> read the application templates (61) in the response: AID (4F), label (50), priority (87)
6A 82 -> no directory: go to step 2 (contactless payment always starts with the directory;
on the contact interface the directory is optional)
2. explicit selection: SELECT each supported AID; on 90 00 also ask for the NEXT occurrence
3. candidate list = card applications that the terminal also supports
4. choose one (automatically or by asking the cardholder), SELECT it, continue the transaction
The module
app-select.mjs builds on the BER-TLV parser from our article How to parse BER-TLV data in JavaScript; save that file as ber-tlv.mjs in the same folder. Every function takes a transmit function, so the same code runs against a real reader or a simulator.
// app-select.mjs — find and select applications on a smart card by AID, host side.
// Ambimat Electronics, 2026. ES module; needs ber-tlv.mjs from our BER-TLV article in the same folder.
// `transmit(bytes) -> Promise<Uint8Array>` is whatever sends one command APDU to your reader
// (PC/SC, a USB CDC bridge, a test double) and returns the raw response including SW1 SW2.
import { parseBerTlv, findTag, hexToBytes, bytesToHex } from './ber-tlv.mjs';
const swOf = (r) => (r[r.length - 2] << 8) | r[r.length - 1];
// SELECT by DF name (AID): CLA 00, INS A4, P1 04; P2 00 = first or only occurrence, 02 = next occurrence.
export function selectCommand(aid, { next = false } = {}) {
const a = typeof aid === 'string' ? hexToBytes(aid) : aid;
if (a.length < 1 || a.length > 16) throw new RangeError(`AID must be 1..16 bytes, got ${a.length}`);
return Uint8Array.of(0x00, 0xa4, 0x04, next ? 0x02 : 0x00, a.length, ...a, 0x00);
}
// Send one command and follow the two common retry signals:
// 61 XX -> XX more bytes are waiting: fetch them with GET RESPONSE (00 C0 00 00 XX), possibly repeatedly
// 6C XX -> wrong Le: send the same command again with Le = XX
export async function exchange(transmit, command, log = []) {
let r = await transmit(command); log.push([command, r]);
if (r.length >= 2 && r[r.length - 2] === 0x6c) {
const retry = Uint8Array.from(command); retry[retry.length - 1] = r[r.length - 1];
r = await transmit(retry); log.push([retry, r]);
}
const data = [];
for (let guard = 0; r.length >= 2 && r[r.length - 2] === 0x61; guard++) {
if (guard === 32) throw new Error('too many GET RESPONSE rounds');
data.push(...r.subarray(0, r.length - 2));
const get = Uint8Array.of(0x00, 0xc0, 0x00, 0x00, r[r.length - 1]);
r = await transmit(get); log.push([get, r]);
}
if (r.length < 2) throw new Error('response shorter than two status bytes');
data.push(...r.subarray(0, r.length - 2));
return { data: Uint8Array.from(data), sw: swOf(r) };
}
// Read what a SELECT response says about the selected application. Different applications use
// different templates, so look for the DF name (84) first and an AID data object (4F) second.
export function describeSelection(data) {
if (data.length === 0) return { aid: null, label: null, tlv: [] };
const tlv = parseBerTlv(data);
const name = findTag(tlv, '84') ?? findTag(tlv, '4F');
const label = findTag(tlv, '50');
return { aid: name ? bytesToHex(name.value).replace(/ /g, '') : null,
label: label ? new TextDecoder().decode(label.value) : null, tlv };
}
// Try each candidate AID (a full AID or a right-truncated prefix). For every candidate, select the
// first occurrence and keep asking for the next occurrence until the card stops answering 90 00.
export async function findApplications(transmit, candidates, { maxPerCandidate = 8 } = {}) {
const found = [], problems = [], log = [];
for (const cand of candidates) {
const seen = new Set();
for (let i = 0, next = false; i < maxPerCandidate; i++, next = true) {
const { data, sw } = await exchange(transmit, selectCommand(cand, { next }), log);
if (sw !== 0x9000) {
if (sw !== 0x6a82) problems.push({ candidate: cand, next, sw: sw.toString(16).toUpperCase() }); // 6A82 = not found
break;
}
const sel = describeSelection(data);
const id = sel.aid ?? `${cand}#${i}`;
if (seen.has(id)) break; // a card that repeats itself must not loop us forever
seen.add(id);
found.push({ candidate: cand, aid: sel.aid, label: sel.label, exact: sel.aid === cand.toUpperCase() });
}
}
return { found, problems, log };
}
// Some cards publish a directory of their applications under a name such as "2PAY.SYS.DDF01"
// (contactless payment) or "1PAY.SYS.DDF01" (contact payment). Selecting it by name returns entries,
// each an application template (61) with the AID (4F) and, when present, a label (50) and a
// priority indicator (87). The priority byte is reported as-is: how to rank it is defined by the
// payment specification you implement.
export async function readDirectory(transmit, name, log = []) {
const { data, sw } = await exchange(transmit, selectCommand(new TextEncoder().encode(name)), log);
if (sw !== 0x9000) return { sw: sw.toString(16).toUpperCase(), entries: [] };
const entries = [];
const walk = (items) => {
for (const it of items) {
if (it.tag === '61' && it.children) {
const aid = findTag(it.children, '4F'), label = findTag(it.children, '50'), prio = findTag(it.children, '87');
if (aid) entries.push({ aid: bytesToHex(aid.value).replace(/ /g, ''), label: label ? new TextDecoder().decode(label.value) : null,
priority: prio && prio.value.length ? prio.value[0] : null });
} else if (it.children) walk(it.children);
}
};
walk(parseBerTlv(data));
return { sw: '9000', entries };
}
// Candidates = card applications the terminal also supports. Exact match by default; with
// { partial: true } a terminal AID also matches any card AID that starts with it. Card order is kept.
export function buildCandidates(entries, terminalAids, { partial = false } = {}) {
const t = terminalAids.map((a) => a.toUpperCase());
const seen = new Set();
return entries.filter((e) => {
const ok = t.some((a) => e.aid === a || (partial && e.aid.startsWith(a)));
if (!ok || seen.has(e.aid)) return false;
seen.add(e.aid); return true;
});
}
What each function does
| Function | Purpose |
|---|---|
selectCommand(aid, { next }) |
Builds 00 A4 04 P2 Lc AID 00: select by name, P2 00 for the first or only occurrence, 02 for the next |
exchange(transmit, command) |
Sends a command and follows 6C XX (resend with the right Le) and 61 XX (fetch the rest with GET RESPONSE) |
describeSelection(data) |
Parses a SELECT response and reports the DF name (84), or the AID data object (4F) when the application uses a template without 84, plus the label (50) |
findApplications(transmit, candidates) |
Explicit selection: for each AID, first occurrence then next occurrences until the card stops answering 90 00; reports unexpected status words |
readDirectory(transmit, name) |
Selects a directory by name and returns its entries |
buildCandidates(entries, terminalAids, { partial }) |
Keeps the card applications the terminal supports, exactly or by prefix |
A test double instead of a card
You can develop and regression-test selection logic without hardware. The simulator below answers SELECT by AID with first/next occurrence and prefix matching, can publish a directory, and can be switched to answer with 61 XX or 6C XX the way some reader stacks do. It is a test double, not a card emulator: real cards differ in detail, so always finish on real hardware.
// simulated-card.mjs — a TEST DOUBLE for app-select.mjs. It models SELECT by AID with first/next
// occurrence and right-truncated (prefix) matching, plus optional 61XX / 6CXX behaviour.
// Real cards differ in detail; always confirm against hardware. Not a card emulator.
const hex = (b) => Array.from(b, (x) => x.toString(16).toUpperCase().padStart(2, '0')).join('');
const fromHex = (h) => Uint8Array.from(h.match(/../g) ?? [], (x) => parseInt(x, 16));
const tlv = (tag, value) => {
const v = Array.from(value);
const len = v.length < 0x80 ? [v.length] : v.length < 0x100 ? [0x81, v.length] : [0x82, v.length >> 8, v.length & 0xff];
return [...fromHex(tag), ...len, ...v];
};
// apps: [{ aid, label, priority? }] in card order. directory: optional name such as '2PAY.SYS.DDF01'
// whose SELECT returns 6F( 84 name, A5( BF0C( 61(4F 50 87) ... ) ) ).
export function simulatedCard(apps, { mode = 'direct', directory = null } = {}) {
let current = -1, pending = null;
const reply = (data, sw) => Uint8Array.of(...data, sw >> 8, sw & 0xff);
return async function transmit(cmd) {
const [cla, ins, p1, p2] = cmd;
if (cla === 0x00 && ins === 0xc0) { // GET RESPONSE
if (!pending) return reply([], 0x6985);
const out = pending; pending = null; return reply(out, 0x9000);
}
if (!(cla === 0x00 && ins === 0xa4 && p1 === 0x04)) return reply([], 0x6d00);
const lc = cmd[4], want = hex(cmd.subarray(5, 5 + lc)), le = cmd[5 + lc];
if (directory && want === hex(new TextEncoder().encode(directory))) {
const entries = apps.flatMap((a) => tlv('61', [...tlv('4F', fromHex(a.aid)), ...tlv('50', new TextEncoder().encode(a.label ?? '')),
...(a.priority == null ? [] : tlv('87', [a.priority]))]));
return reply(tlv('6F', [...tlv('84', new TextEncoder().encode(directory)), ...tlv('A5', tlv('BF0C', entries))]), 0x9000);
}
const start = p2 === 0x02 ? current + 1 : 0;
const idx = apps.findIndex((a, i) => i >= start && a.aid.startsWith(want));
if (idx < 0) return reply([], 0x6a82);
const name = fromHex(apps[idx].aid);
const label = new TextEncoder().encode(apps[idx].label ?? '');
const fci = [0x6f, 4 + name.length + label.length, 0x84, name.length, ...name, 0x50, label.length, ...label];
if (mode === '6C' && le !== fci.length) return reply([], 0x6c00 | fci.length);
current = idx;
if (mode === '61') { pending = fci; return reply([], 0x6100 | fci.length); }
return reply(fci, 0x9000);
};
}
Example: two cards, two discovery paths
Card A publishes a directory; card B carries the same two applications but no directory. The terminal supports three AIDs. The AIDs here are real identifiers used for illustration (see our AID list); the labels are made up.
// discover.mjs — application discovery against two simulated cards, printing every APDU exchanged.
// Card A publishes a directory; card B does not, so the terminal falls back to explicit selection.
import { readDirectory, buildCandidates, findApplications, exchange, selectCommand } from '../app-select.mjs';
import { simulatedCard } from '../simulated-card.mjs';
const hx = (b) => Array.from(b, (x) => x.toString(16).toUpperCase().padStart(2, '0')).join(' ');
const printLog = (log) => log.forEach(([c, r]) => console.log(` > ${hx(c)}\n < ${hx(r)}`));
const terminalAids = ['A0000000041010', 'A0000000031010', 'A0000000651010']; // what this terminal accepts
const cardApps = [{ aid: 'A0000000041010', label: 'APP ONE', priority: 1 }, { aid: 'A0000000651010', label: 'APP TWO', priority: 2 }];
for (const [name, card] of [['card A (directory)', simulatedCard(cardApps, { directory: '2PAY.SYS.DDF01' })],
['card B (no directory)', simulatedCard(cardApps)]]) {
console.log(`== ${name}`);
const log = [];
const dir = await readDirectory(card, '2PAY.SYS.DDF01', log);
let candidates;
if (dir.entries.length) {
candidates = buildCandidates(dir.entries, terminalAids);
} else {
console.log(` directory not available (SW ${dir.sw}): selecting each supported AID explicitly`);
const r = await findApplications(card, terminalAids);
log.push(...r.log);
candidates = r.found.map((f) => ({ aid: f.aid, label: f.label, priority: null }));
}
printLog(log);
console.log(' candidates:', candidates.map((c) => `${c.aid} "${c.label}"${c.priority == null ? '' : ` priority=${c.priority}`}`).join(', '));
const chosen = candidates[0];
const final = await exchange(card, selectCommand(chosen.aid));
console.log(` final SELECT ${chosen.aid}: SW ${final.sw.toString(16).toUpperCase()}`);
}
Output (> command, < response):
== card A (directory)
> 00 A4 04 00 0E 32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 00
< 6F 43 84 0E 32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 A5 31 BF 0C 2E 61 15 4F 07 A0 00 00 00 04 10 10 50 07 41 50 50 20 4F 4E 45 87 01 01 61 15 4F 07 A0 00 00 00 65 10 10 50 07 41 50 50 20 54 57 4F 87 01 02 90 00
candidates: A0000000041010 "APP ONE" priority=1, A0000000651010 "APP TWO" priority=2
final SELECT A0000000041010: SW 9000
== card B (no directory)
directory not available (SW 6A82): selecting each supported AID explicitly
> 00 A4 04 00 0E 32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 00
< 6A 82
> 00 A4 04 00 07 A0 00 00 00 04 10 10 00
< 6F 12 84 07 A0 00 00 00 04 10 10 50 07 41 50 50 20 4F 4E 45 90 00
> 00 A4 04 02 07 A0 00 00 00 04 10 10 00
< 6A 82
> 00 A4 04 00 07 A0 00 00 00 03 10 10 00
< 6A 82
> 00 A4 04 00 07 A0 00 00 00 65 10 10 00
< 6F 12 84 07 A0 00 00 00 65 10 10 50 07 41 50 50 20 54 57 4F 90 00
> 00 A4 04 02 07 A0 00 00 00 65 10 10 00
< 6A 82
candidates: A0000000041010 "APP ONE", A0000000651010 "APP TWO"
final SELECT A0000000041010: SW 9000
Reading the trace:
- The first command selects the directory by its name, written as ASCII bytes:
32 50 41 59 2E 53 59 53 2E 44 44 46 30 31is2PAY.SYS.DDF01. - Card A’s answer nests the entries:
6Ftemplate, DF name84, then insideA5andBF0Cone61template per application with its AID (4F), label (50) and priority indicator (87). The code does not depend on that exact nesting; it collects every61template wherever it appears. - Card B answers
6A 82, so the terminal selects each AID it supports. Each90 00is followed by a SELECT with P202(next occurrence), which here returns6A 82because each card holds only one instance of each application. The AID the terminal supports but the card does not carry also returns6A 82, which is normal. - The final SELECT re-selects the chosen application by its full AID before the transaction continues.
Troubleshooting application selection
| Symptom | Likely cause | What to check |
|---|---|---|
Directory SELECT returns 6A 82 |
The card has no directory on this interface | Contact interface: fall back to explicit selection. Contactless payment always starts with the directory, so treat a missing one there as a fault to investigate |
Every SELECT returns 6A 82 |
Wrong AID bytes, wrong interface, or a non-payment card | Compare the AID byte for byte; count bytes, not hex characters; try a card you know |
SELECT returns 6A 86 |
The card does not support the selection option you asked for (for example selection by a partial AID) | Retry with P2 00 and the full AID |
SELECT returns 61 XX or 6C XX with no data |
Your stack passes T=0 procedure bytes through | Use exchange(), or a library call that handles them (Java’s CardChannel.transmit does) |
| The same application appears twice in the candidate list | The directory and explicit selection were merged, or one AID matched as a prefix of another | De-duplicate by full AID (the module does); decide whether prefix matching is wanted |
| Next-occurrence loop never ends | The card keeps returning the same application | Stop when an AID repeats (the module does) and cap the number of rounds |
| A card with two applications only ever shows one | The terminal matches exact AIDs only, but the card’s second application has a longer AID with the same prefix | Support partial (prefix) matching with next-occurrence selection; industry guidance recommends it precisely so that multiple occurrences of an application can be found |
| Label prints as garbage | Label decoded with the wrong character set, or status bytes parsed as data | Strip SW1 SW2 before parsing; log the raw bytes as hex (see our article on turning reader output into text) |
| Phone emulating a card ignores a partial AID | Android host card emulation does not support partial-match selection by the reader | Select the full AID that the app registered |
Testing strategy
- Unit tests against a simulator for the logic: exact, partial and missing AIDs, directory present and absent,
61/6Ctransports, cards that repeat themselves, unexpected status words. The tests used for this page are below. - Replay of recorded traces. Log every exchange with a timestamp and direction (hex, never decoded text), and turn interesting sessions into fixtures. A failure in the field can then be reproduced at your desk.
- Hardware. Real cards and readers reveal what a simulator cannot: timing, interface differences between contact and contactless, cards that answer outside the documented options. For payment acceptance, certification testing with the schemes’ test cards and tools is required in addition.
// app-select.test.mjs — node --test app-select.test.mjs (SELECT_IMPL=<path> tests another copy)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { simulatedCard } from './simulated-card.mjs';
const impl = process.env.SELECT_IMPL ? new URL(process.env.SELECT_IMPL, `file://${process.cwd()}/`).href : './app-select.mjs';
const S = await import(impl);
const apps = [
{ aid: 'A0000000041010', label: 'APP ONE' },
{ aid: 'A00000000410101213', label: 'APP ONE B' },
{ aid: 'A000000308000010000100', label: 'PIV' },
];
const hx = (b) => Array.from(b, (x) => x.toString(16).toUpperCase().padStart(2, '0')).join(' ');
test('SELECT command bytes', () => {
assert.equal(hx(S.selectCommand('A000000308000010000100')), '00 A4 04 00 0B A0 00 00 03 08 00 00 10 00 01 00 00');
assert.equal(hx(S.selectCommand('A0000000041010', { next: true })), '00 A4 04 02 07 A0 00 00 00 04 10 10 00');
assert.throws(() => S.selectCommand(''), RangeError);
assert.throws(() => S.selectCommand('00'.repeat(17)), RangeError);
});
for (const mode of ['direct', '61', '6C']) {
test(`exact, partial and missing candidates (card mode ${mode})`, async () => {
const r = await S.findApplications(simulatedCard(apps, { mode }), ['A000000308000010000100', 'A000000004', 'A0000000999999']);
assert.deepEqual(r.found.map((f) => [f.candidate, f.aid, f.label, f.exact]), [
['A000000308000010000100', 'A000000308000010000100', 'PIV', true],
['A000000004', 'A0000000041010', 'APP ONE', false],
['A000000004', 'A00000000410101213', 'APP ONE B', false],
]);
assert.deepEqual(r.problems, []);
if (mode === '61') assert.ok(r.log.some(([c]) => c[1] === 0xc0), 'GET RESPONSE was used');
if (mode === '6C') assert.ok(r.log.some(([c, resp]) => resp[resp.length - 2] === 0x6c), '6C retry was used');
});
}
test('a card that keeps returning the same application does not loop forever', async () => {
const stuck = async () => Uint8Array.of(0x6f, 0x05, 0x84, 0x03, 0xa0, 0x00, 0x01, 0x90, 0x00);
const r = await S.findApplications(stuck, ['A00001']);
assert.equal(r.found.length, 1);
});
test('unexpected status words are reported, not swallowed', async () => {
const locked = async () => Uint8Array.of(0x69, 0x85);
const r = await S.findApplications(locked, ['A0000000041010']);
assert.deepEqual(r.problems, [{ candidate: 'A0000000041010', next: false, sw: '6985' }]);
});
test('describeSelection reads DF name (84), falls back to AID (4F)', () => {
assert.equal(S.describeSelection(Uint8Array.of(0x6f, 0x05, 0x84, 0x03, 0xa0, 0x00, 0x01)).aid, 'A00001');
assert.equal(S.describeSelection(Uint8Array.of(0x61, 0x05, 0x4f, 0x03, 0xa0, 0x00, 0x02)).aid, 'A00002');
assert.equal(S.describeSelection(new Uint8Array(0)).aid, null);
});
test('directory: entries with AID, label and priority; candidates exact and partial', async () => {
const card = simulatedCard([{ aid: 'A0000000041010', label: 'APP ONE', priority: 1 }, { aid: 'A0000000651010', label: 'APP TWO' }],
{ directory: '2PAY.SYS.DDF01' });
const dir = await S.readDirectory(card, '2PAY.SYS.DDF01');
assert.deepEqual(dir.entries, [{ aid: 'A0000000041010', label: 'APP ONE', priority: 1 }, { aid: 'A0000000651010', label: 'APP TWO', priority: null }]);
assert.deepEqual(S.buildCandidates(dir.entries, ['A0000000651010', 'A0000000031010']).map((e) => e.aid), ['A0000000651010']);
assert.deepEqual(S.buildCandidates(dir.entries, ['A000000004']).length, 0);
assert.deepEqual(S.buildCandidates(dir.entries, ['A000000004'], { partial: true }).map((e) => e.aid), ['A0000000041010']);
assert.deepEqual(S.buildCandidates([...dir.entries, dir.entries[0]], ['A0000000041010']).length, 1);
});
test('no directory: 6A82, so fall back to explicit selection', async () => {
const dir = await S.readDirectory(simulatedCard(apps), '2PAY.SYS.DDF01');
assert.deepEqual(dir, { sw: '6A82', entries: [] });
});
Frequently asked questions
Why not just SELECT the AID I want?
If you know exactly which application you need (an access-control applet, a PIV card), that is the right approach. Discovery matters when the card may carry several applications and the terminal must find the ones both sides support.
Why is the priority value not used to sort the candidates?
How the priority indicator is interpreted, and when the cardholder must be asked to choose, is defined by the payment specifications, not by the generic mechanism. The module reports the raw value so your scheme-specific code can apply the right rule.
Does this work over NFC on Android?
Yes: wrap IsoDep.transceive() in a transmit function. Check isExtendedLengthApduSupported() before sending long commands.
Related reading on Ambimat
- Application discovery and selection on chip cards – the concepts behind this code.
- Smart card APDU commands – SELECT, status words, short and extended length.
- Application identifier (AID) list – identifiers to put in the terminal’s list.
- Payment and smart-card BER-TLV tag reference – tags such as
4F,50,84and87.
Sources
- ETSI TS 102 221 V18.4.0 – §11.1.1 SELECT (selection by DF name/AID, P2 first/last/next/previous occurrence, right-truncated AID), §8.5.1 partial AID selection, §7.3.1.1.5 procedure bytes
61XX/6CXX: etsi.org - NIST SP 800-73pt2-5 (2024), §3.1.1 SELECT (full or right-truncated PIV AID): nvlpubs.nist.gov
- U.S. Payments Forum, EMV Troubleshooting Guide for ATM Owners and Operators (2017), §5 application selection, candidate list, PSE and explicit selection, partial AID matching: uspaymentsforum.org
- U.S. Payments Forum, U.S. Debit EMV Technical Solution v1.4 (2018), §6.1 and §8.2 (application choice; contactless transactions start with the PPSE): uspaymentsforum.org
- M. Roland, J. Langer, Cloning Credit Cards: A combined pre-play and downgrade attack on EMV Contactless, USENIX WOOT 2013 (PPSE response with AIDs and priority indicator): usenix.org
- S. J. Murdoch, S. Drimer, R. Anderson, M. Bond, Chip and PIN is Broken, IEEE S&P 2010 (contact transaction trace beginning with “1PAY.SYS.DDF01”): cl.cam.ac.uk
- Android Developers, Host-based card emulation overview (AID routing; partial matches not supported): developer.android.com;
IsoDep: developer.android.com - Oracle, Java SE 21 API,
javax.smartcardio.CardChannel.transmit(automatic handling of61XX/6CXX): docs.oracle.com
Written and tested by the Ambimat engineering team. The module, simulator and example were run on Node.js 25.8.2.