EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Hardware / Reading ID Cards from the Web with a Localhost Agent
Hardware

Reading ID Cards from the Web with a Localhost Agent

by Ton-Or ·16 Oct 2026·11 min read
Reading ID Cards from the Web with a Localhost Agent

The task sounded small: click a button on a web page and have the card data appear in a form. But between the web page and the card reader sitting half a meter away, there are several layers of browser restrictions, and the final layer is antivirus that does not believe my program is not a backdoor.

I had a job where counter staff had to enter information from people's ID cards into a system several dozen times a day. They typed every field by hand, sometimes correctly and sometimes not, and there was already a card reader sitting on the desk unused because the old system was a locally installed program while the new one was web-based. I took on the job of bridging the two. I estimated two days; it actually took two weeks, because the code that read the card was finished on the first day. Everything else was getting the browser to talk to it and getting antivirus to let it run.

All card data and names in this article are fictional. The IP addresses and ports are examples, but the code and the symptoms described are real.

Why browsers cannot access card readers directly

The first question everyone asks is why the web page cannot talk directly to the reader. The answer is that browsers have no way to access the operating system's PC/SC layer. That barrier was intentional from the start. If any web page could read smart cards connected to our computers, things would have fallen apart long ago.

There are three practical options. The first is a browser extension, which needs a separate implementation for each browser vendor and breaks whenever the browser changes its extension architecture. The second is a full desktop application, which brings back the same problem I was trying to escape. The third is a small agent running in the background that opens an HTTP port on localhost and lets the web page call it through fetch. I chose the third because it was the only option that required almost no changes to the existing web page.

Data path from card to formCardin the reader→PC/SCOS layer→agent127.0.0.1 local port→fetchfrom the https page→Formfilled automatically

https can talk to http on 127.0.0.1, but you need to understand the conditions

The first barrier was mixed content. The web page used https, but the agent used plain http. Browsers would normally block this immediately, but there is an important exception: loopback destinations are considered trustworthy origins. Modern browsers therefore allow requests from https to http://127.0.0.1.

A common mistake is using http://localhost instead, which is not the same because localhost requires name resolution first. On a machine with unusual DNS settings or a corporate proxy in between, it might not point back to the local machine. The result is something that works on the developer's machine and then breaks on site for no apparent reason. I therefore always use 127.0.0.1 directly.

Use 127.0.0.1, not the name localhost

Both seem identical on my machine, but localhost requires name resolution first, which corporate DNS or proxy policies can interfere with. On machines with IPv6 enabled, the name may resolve to ::1 first. If the agent only listens on IPv4, the connection fails immediately. Using the address directly eliminates both problems at once.

The web page only needs this simple call, but connection failures need proper handling because staff need a message telling them to open the program in the bottom-right corner, not a raw error message.

async function readCard() {
  const ports = [8189, 8289, 8389];          // ต้องตรงกับลำดับ fallback ฝั่ง agent
  for (const p of ports) {
    try {
      const r = await fetch(`http://127.0.0.1:${p}/read`, {
        method: "GET", mode: "cors", cache: "no-store",
        signal: AbortSignal.timeout(6000),
      });
      if (r.ok) return await r.json();
    } catch (e) { /* ลองพอร์ตถัดไป */ }
  }
  throw new Error("ไม่พบโปรแกรมอ่านบัตร กรุณาเปิดโปรแกรมที่มุมขวาล่างของจอ");
}

CORS and the preflight responses the agent must get right

The second barrier is CORS. The web page is necessarily on a different origin from the agent, so the agent must return the correct permission headers. More importantly, it must handle OPTIONS properly instead of returning 404 as though it were an unknown path.

My rule is never to return Access-Control-Allow-Origin: *, even though it is much more convenient, because that would mean any website staff happened to open in the same browser could immediately request a read of the inserted card. I use a list of allowed origins and echo back only a matching origin.

Web pageagentCard readerOPTIONS /read with Origin204 with CORS headersNo origin match, no headersMax-Age 600 skips 1-2 for ten minutesGET /readSCardConnect SHARED + APDUField data (TIS-620)JSON in UTF-8
The browser sends OPTIONS first. The agent returns 204 with CORS headers only for origins on the list. Setting Max-Age lets it skip this step for ten minutes. Next comes GET /read, which reads the card in SHARED mode.
static const char* ALLOWED[] = {
    "https://app.example.co.th",
    "https://staging.example.co.th",
    nullptr
};

static bool origin_allowed(const std::string& origin) {
    for (int i = 0; ALLOWED[i]; ++i)
        if (origin == ALLOWED[i]) return true;
    return false;
}

static std::string cors_headers(const std::string& origin) {
    if (!origin_allowed(origin)) return "";          // ไม่ตรง ไม่ใส่หัวข้อให้เลย
    return "Access-Control-Allow-Origin: " + origin + "\r\n"
           "Vary: Origin\r\n"
           "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
           "Access-Control-Allow-Headers: Content-Type\r\n"
           "Access-Control-Max-Age: 600\r\n";
}
// OPTIONS ต้องตอบ 204 พร้อมหัวข้อ CORS ห้ามตกไปเป็น 404
if (method == "OPTIONS") {
    std::string h = cors_headers(origin);
    std::string res = "HTTP/1.1 204 No Content\r\n" + h +
                      "Content-Length: 0\r\nConnection: close\r\n\r\n";
    send_all(sock, res);
    return;
}
Max-Age helps performance more than you might expect

Without Max-Age, the browser sends OPTIONS before every card read. At a counter reading several dozen cards in succession per hour, that means twice as many connections for no benefit. About ten minutes is a reasonable setting. Any longer, and changes to the allowed origins list take longer to take effect.

Port conflicts with other agents and the SO_REUSEADDR trap on Windows

Counter machines do not run only my software. Many already have card-reading software installed for other systems, and a port conflict stops everything. I therefore added three fallback ports, trying each in turn until one could be reserved, with the web page trying them in the same order.

One point that needs particular care is that SO_REUSEADDR means something different on Windows than on Unix. On Windows, it allows another process to bind to the same port. My program could therefore bind successfully even though something else was already listening, and requests would be randomly split between them. The visible symptom is that card reads sometimes work and sometimes fail, alternating without an obvious cause. The option to use is SO_EXCLUSIVEADDRUSE, which forces the bind to fail if the port is already in use.

static SOCKET bind_local(int port) {
    SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s == INVALID_SOCKET) return INVALID_SOCKET;

    BOOL excl = TRUE;   // บนวินโดวส์ต้องตัวนี้ ไม่ใช่ SO_REUSEADDR
    setsockopt(s, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, (char*)&excl, sizeof(excl));

    sockaddr_in a{};
    a.sin_family = AF_INET;
    a.sin_port   = htons((u_short)port);
    inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);   // ฟังเฉพาะวงลูปแบ็ก

    if (bind(s, (sockaddr*)&a, sizeof(a)) == SOCKET_ERROR ||
        listen(s, 8) == SOCKET_ERROR) {
        closesocket(s);
        return INVALID_SOCKET;
    }
    return s;
}

SOCKET listen_with_fallback(int* chosen) {
    const int ports[] = {8189, 8289, 8389};
    for (int p : ports) {
        SOCKET s = bind_local(p);
        if (s != INVALID_SOCKET) { *chosen = p; return s; }
    }
    return INVALID_SOCKET;
}

Another requirement is to bind only to 127.0.0.1, never to all interfaces. Otherwise, other machines on the same network could request a read of the card inserted at the counter. This matters even more than CORS, because CORS is enforced by browsers, while a custom program can ignore it entirely.

Bind to all interfaces

The whole LAN can request card reads

Convenient for testing, but it means anyone on the same network can send requests directly without going through a browser. The CORS rules I configured offer no protection here.

Bind only to 127.0.0.1

Only programs on the same machine

This reduces the attack surface from the whole network to one machine, with CORS as the next layer controlling which web pages can make calls. These layers serve different purposes. Both are needed.

Talk to the card through PC/SC in SHARED mode without monopolizing the reader

Now for the agent's actual job: talking to the card. The Windows PC/SC layer offers two connection modes. Choosing EXCLUSIVE gives my program sole use of the reader, immediately preventing other programs that were already using it from opening it. That is the fastest way to upset the site's IT team. I always use SHARED, connect only when I actually need to read, and disconnect as soon as the read finishes.

There is also the question of waiting for a card. Do not poll the status frequently, because that wastes CPU on counter machines that are often old. Using SCardGetStatusChange to block until the status actually changes is much better.

SCARDCONTEXT ctx;
SCardEstablishContext(SCARD_SCOPE_USER, nullptr, nullptr, &ctx);

SCARD_READERSTATE st{};
st.szReader      = reader_name;              // ชื่อเครื่องอ่านจาก SCardListReaders
st.dwCurrentState = SCARD_STATE_UNAWARE;

// บล็อกรอจนสถานะเปลี่ยน ไม่ใช่วน poll ทุก 200 ms
LONG rc = SCardGetStatusChange(ctx, 5000, &st, 1);
if (rc == SCARD_S_SUCCESS && (st.dwEventState & SCARD_STATE_PRESENT)) {
    SCARDHANDLE h; DWORD proto = 0;
    // SHARED เท่านั้น อย่ายึดเครื่องอ่านไว้คนเดียว
    SCardConnect(ctx, reader_name, SCARD_SHARE_SHARED,
                 SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, &h, &proto);
    read_fields(h, proto);
    SCardDisconnect(h, SCARD_LEAVE_CARD);     // ปล่อยคืน ไม่รีเซ็ตบัตร
}

Communication with the card uses APDU commands. The sequence is to select the application on the card first, then read one range at a time. With the T=0 protocol, each read must always be followed by another command to retrieve the result. This is a common mistake that produces empty data without an error.

// เลือกแอปพลิเคชันบนบัตรก่อน จากนั้นอ่านทีละช่วงตามออฟเซ็ต
static const BYTE SELECT[] = {0x00,0xA4,0x04,0x00,0x08,
                              0xA0,0x00,0x00,0x00,0x54,0x48,0x00,0x01};

// คำขออ่าน แล้วตามด้วยคำสั่งขอผลลัพธ์ (จำเป็นสำหรับ T=0)
static std::vector<BYTE> read_field(SCARDHANDLE h, DWORD proto,
                                    BYTE off_hi, BYTE off_lo, BYTE len) {
    BYTE cmd[]  = {0x80,0xB0, off_hi, off_lo, 0x02, 0x00, len};
    BYTE getr[] = {0x00,0xC0,0x00,0x00, len};
    BYTE buf[260]; DWORD n = sizeof(buf);
    const SCARD_IO_REQUEST* pci =
        (proto == SCARD_PROTOCOL_T1) ? SCARD_PCI_T1 : SCARD_PCI_T0;

    SCardTransmit(h, pci, cmd, sizeof(cmd), nullptr, buf, &n);
    n = sizeof(buf);
    SCardTransmit(h, pci, getr, sizeof(getr), nullptr, buf, &n);
    if (n < 2) return {};
    return std::vector<BYTE>(buf, buf + n - 2);     // ตัด status word สองไบต์ท้าย
}
Read only what is needed, and do not store it locally

ID card data is personal information in every sense. The rule I enforce in the agent is to read only the fields the destination form actually needs, return them over loopback, and immediately discard them from memory. Do not write them to files or logs, even while debugging. The only information allowed in the log is the read time and whether it succeeded.

Thai text from the card is TIS-620: manual decoding, fonts, and DPI

The text data from the card is not UTF-8; it is TIS-620. A shortcut many people use is to call a system conversion function based on the machine's code page, which works as long as the machine's regional settings are Thai. On a machine set to English or with system-wide UTF-8 mode enabled, the text immediately becomes garbage. I therefore decode it myself using a fixed table, without relying on system settings. The table is straightforward because the range 0xA1 to 0xFB maps directly to the Thai character range in Unicode by adding a constant offset.

// TIS-620 -> UTF-8 ไม่พึ่งโค้ดเพจของระบบ
static std::string tis620_to_utf8(const std::vector<BYTE>& in) {
    std::string out;
    for (BYTE b : in) {
        if (b < 0x80) { out.push_back((char)b); continue; }
        if (b >= 0xA1 && b <= 0xFB) {
            unsigned cp = 0x0E00 + (b - 0xA0);      // ช่วงอักษรไทยในยูนิโค้ด
            out.push_back((char)(0xE0 | (cp >> 12)));
            out.push_back((char)(0x80 | ((cp >> 6) & 0x3F)));
            out.push_back((char)(0x80 | (cp & 0x3F)));
        }
        // ไบต์นอกช่วงคือช่องว่างหรือของที่ไม่ได้ใช้ ข้ามไป
    }
    while (!out.empty() && out.back() == ' ') out.pop_back();   // ตัดช่องว่างท้าย
    return out;
}

The result returned to the web page is plain JSON. All data in the example below is fictional.

{
  "ok": true,
  "reader": "Generic Smart Card Reader 0",
  "data": {
    "cid": "0-0000-00000-00-0",
    "th_name": "นาย ตัวอย่าง ทดสอบระบบ",
    "en_name": "Mr. Tuayang Thodsobrabob",
    "birth": "2530-01-01",
    "issue": "2565-01-01",
    "expire": "2573-01-01"
  }
}

There are two things to handle from the start for the program's appearance. The first is to embed a Thai font in the executable. Do not rely on fonts installed on the machine, because counter machines with heavily stripped-down Windows installations may lack fonts I would expect to be there. The second is to declare the program DPI-aware from the start. Otherwise, on displays scaled above one hundred percent, the text becomes too blurry to read.

<!-- ใส่ใน manifest ของ exe ไม่งั้นบนจอสเกล 150% ตัวหนังสือเบลอทั้งหน้าต่าง -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
  <windowsSettings>
    <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
    <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2</dpiAwareness>
  </windowsSettings>
</application>

Antivirus flagged it as a backdoor

The final barrier was one I had not prepared for at all. When I copied the file to an actual machine, antivirus deleted it before I could even open it. Once I checked the reason, I immediately understood why: my program matched almost every characteristic of a backdoor.

420 KBUnusually small file size
3 portsTried in turn until one can listen
NoneReputation history
2 weeksActual time spent on the whole job

Three things actually solved the problem. The first was signing the file with a code-signing certificate. This was the deciding factor and an expense I had to accept. The second was filling in all the version and publisher information in the file. A file with none of that information looks much more suspicious. The third was compiling with the libraries statically linked into a single executable, avoiding a dependency on a redistributable that might not be installed on the target machine.

# คอมไพล์ให้เป็นไฟล์เดียวจบ ไม่ต้องพึ่ง runtime ภายนอก และบังคับซอร์สเป็น UTF-8
cl /std:c++17 /MT /utf-8 /O2 /DNDEBUG ^
   agent.cpp /link winscard.lib ws2_32.lib /OUT:card-agent.exe

# เซ็นไฟล์ด้วยใบรับรองสำหรับเซ็นโค้ด ตัวนี้คือตัวตัดสินจริง
signtool sign /fd SHA256 /tr http://timestamp.example.org /td SHA256 card-agent.exe
Do not fix this by excluding the entire folder

The shortcut the on-site team often suggests is adding an exclusion for the program's entire folder. That does fix the symptom, but it also opens a hole for anything placed in that folder in the future, and it has to be repeated on every machine. The right approach is to sign the file properly, resolving the issue across all machines at once.

The architecture I used and the order to follow

  1. Decide on the architecture first. A loopback agent requires the fewest changes to the existing web page and does not tie it to any browser vendor.
  2. On the web side, always use 127.0.0.1, with multiple fallback ports and error messages that counter staff can understand.
  3. On the agent side, bind only to loopback, use SO_EXCLUSIVEADDRUSE, and try each port until one is actually reserved.
  4. Respond correctly to OPTIONS with 204 and CORS headers, echoing only origins on the allowed list. Never use an asterisk.
  5. Connect to PC/SC in SHARED mode, wait with SCardGetStatusChange, and disconnect as soon as the read finishes.
  6. Decode TIS-620 with a fixed table instead of relying on the machine's code page. Embed a Thai font and declare DPI awareness in the executable.
  7. Sign the file with a code-signing certificate before the first real deployment. Do not wait until it gets deleted.

My view is that, for work like this bridging browsers and hardware, the device protocol is not where the time actually goes. The code that reads the card is fewer than two hundred lines. The rest is making it survive on someone else's machine, where other programs are already installed, corporate security policies are in force, and the users should not have to understand what CORS is. Anyone wanting to use this architecture for other devices browsers cannot access, such as scales or specialized printers, can use the same structure throughout. Only the layer that talks to the device needs to change.

Still on my list is an automatic updater for the agent, because every fix currently means walking from counter to counter, along with switching from one-off requests to WebSocket so the web page knows immediately when someone inserts a card, without staff having to press a button. If I have left anything out or made any typos, I will come back and correct them later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first