Measured errata for Taiwan's Digital Identity Wallet (TWDIW), written while building a third-party holder. It complements denkeni's Missing Manual, which explains what the tools are and how the flows run. This page is about the things you only find out by running into them: where the documentation and the deployment disagree, where the specs are bent, and which mistakes pass your tests and fail in the field.
English, because it complements an English document and has to be sendable upstream as-is. A full 中文版 is maintained alongside it, in the same commit. All measurements taken 2026-08-16 against production endpoints, read-only.Every claim below carries an evidence grade. This matters more than usual here: some of these are defect reports aimed at a government system, and a claim that cannot survive "did you actually test that?" damages the ones that can.
curl is given. Anyone can re-run it. Date of measurement is stated because these endpoints change.
moda-gov-tw/TWDIW-official-app, read at HEAD on 2026-08-16 (last push 2026-07-27). ⚠️ Reading the published source does not establish what the deployed build does. Where a claim is about live behaviour and the evidence is only source, it says so.
What this page does not have: we have never actually collected a credential. Nothing here required issuing one, and nothing here should be read as "we ran the whole flow and it worked". The one step that needs a form submission is the one step we have not taken.
中文摘要:這一頁是做第三方皮夾時撞到的牆——文件與實際部署的差別、規範偏差、 量到的數字,以及八件要回報上游的缺陷。每一條都標了證據等級,因為其中幾條是要送到 數發部手上的,而一條經不起「你們實測過嗎」的主張會連累其他站得住的。
Not "it is hard". The role does not exist in the documentation, the sandbox, or the data model.
| Role | What you must do to participate |
|---|---|
| Issuer | Apply for a sandbox account → self-host → UAT with the TWDIW team → production |
| Verifier | Same, with the verifier stack |
| Wallet (holder) | Not documented. No application form, no registration, no attestation. |
The account application page is addressed to "products, services or programs integrating with
the sandbox", and points at issuer-sandbox and verifier-sandbox.
There is no wallet-sandbox. In the Missing Manual itself, the section
headed "If You're Building a Third-Party Issuer or Verifier Software" is empty,
and third-party wallets do not have a heading at all.
client_id is not a registry — it is an echo
The issuer signs an ID Token whose aud is the hardcoded string "moda_dw"
(twdiw-vc-handler/…/CredentialService.java). The OID4VCI handler then reads
client_id back out of that aud
(twdiw-oid4vci-handler/…/CredentialIssuer.java) and stores it with the
pre-authorized code. At /token the wallet's client_id is matched
against that stored value, and the proof JWT's iss is matched against the
same value again.
client_secret, no client assertion, no mTLS, no Play Integrity,
no App Attest, no DeviceCheck.
AccessTokenFilter's validation is commented out with the note
「尚未啟用 AccessToken 驗證」 while /api/** is permitAll.
tx_code. The defensible
statement is narrower and stronger:
This is where a wallet fails first, and it fails as a parse error two layers away from the cause.
TWDIW uses multicodec 0xEB51 (jwk_jcs-pub), whose
payload is the JWK itself as JSON text. Not p256-pub (0x1200), which
carries a 33-byte compressed point and is what most did:key implementations emit for P-256.
Decoded byte by byte from a real trust-list entry:
129 bytes total
first 3 bytes = D1 D6 03 # LEB128 varint for 0xEB51
remaining 126 = {"crv":"P-256","kty":"EC","x":"kY6ina…","y":"c5je-…"}
The server side strips it with a hardcoded three-byte slice, checking neither the multibase character nor the multicodec:
String did_hex_prefix = CryptoHelper.convertBinaryToHexString(Base58.decode(did.substring(1)));
String did_hex = did_hex_prefix.substring(6); // 去掉prefix D1D603
String did_jwk = new String(CryptoHelper.hexStringToByteArray(did_hex));
So a did:key:zDn… handed to TWDIW loses three bytes and is then parsed as JSON.
You get a JSON error, and the actual cause — the wrong multicodec — is not mentioned anywhere.
0xEB51 ×43, JWK member
order crv,kty,x,y ×43, self-signature verifies ×43, embedded DID document
id matches ×43, header jwk matches the DID's key ×43.
The issuer side is clean.
jwk_jcs-pub means the JWK is canonicalised per RFC 8785, which orders members
crv < kty < x < y. The one
holder DID we have decoded is crv, x, y, kty — kty last.
kid against an issuer, a presenter's
DID against credentialSubject.id. Close it by comparing the
canonical form, computed from the key, rather than the string as it
arrived. Two DIDs name one key exactly when their canonical forms match, and no spelling
can hide behind another.
Test vectors. Both are real, from production:
# Ministry of Digital Affairs (canonical — must round-trip to itself)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbrzifm9txeerMVc9oLUg2nBJJnUtgYcAYd35rw1rCLq8y3bLDDBUPH5yTYB7ocY7oPESPBXqubuwMcRzw9evbeHHyFkwsmDc43myibDChGhDk8zrgZDB4KNyXPiQvkktUwn
# A wallet holder DID (NOT canonical — must still decode)
did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPodrZSqMbCy9Ndu4UgUGy3RNkhH479eLPpbfAhVSNu7B4oJvUwLzyxiP4Jt5k9cqqmChanxAazTGxJMvGxYDApNkXeDW5MPZgZRkjRgD1yaig5KCEgAaVbg8zrvYjMTi1BzqdDpPpkeSFmJwiej9YNY
This is the single most expensive thing to get wrong, because the wrong implementation passes every test you would think to write.
jwt_vc_json, for everything
GET https://issuer-oid4vci.wallet.gov.tw/api/issuer/00000000/.well-known/openid-credential-issuer
→ 701,000 bytes, 882 credential configurations.
format distribution: {jwt_vc_json: 882}. Occurrences of
the string sd-jwt in the whole document: 0. Of _sd: 0. Of
selective or disclosure: 0.
typ: vc+sd-jwt with ~-separated
disclosures. A conformant OID4VCI client has no field it can read to discover this.
The ~ is the only signal there is.
_sd entries from the same credential.
base64url(SHA-256(<the disclosure's own base64url string, ASCII>))
reproduces both. Hashing the decoded bytes reproduces neither.
python3 - <<'EOF'
import hashlib, base64
b64u_e = lambda b: base64.urlsafe_b64encode(b).decode().rstrip('=')
D = "WyJwbHowWFN6LW9CSEUwZTUzTFVBeWNBIiwiaWRfbnVtYmVyIiwiQTIzNDU2Nzg5MCJd"
print(b64u_e(hashlib.sha256(D.encode('ascii')).digest()))
# ApkeYAR85EzxAHS1ojnNHhG7wnCDyTt4_iCIX2VKxaw ← the published _sd entry
EOF
vc claim
_sd and _sd_alg live under vc.credentialSubject, not at
the top level. The type is vc.type[1], not vct. So a reader written
strictly to SD-JWT VC looks in the wrong place, and one written strictly to VCDM never splits
the disclosures. Both parse; both are wrong.
com.authlete:sd-jwt, which is
why the digest semantics are correct even though the metadata does not admit the format exists.
statusListIndex is a string
"statusListIndex": "35", not 35. A reader that accepts only numbers
drops the whole credentialStatus object — and a credential with no status entry
is a credential that is never checked for revocation. Silent, and in the safe-looking direction.
Smaller than you would guess, anchored better than you would guess, and labelled in a way you must not show to a user.
size is not honoured
GET https://frontend.wallet.gov.tw/api/did?size=20&page=0&orgType=1&status=1
orgType=1, 23 at orgType=2, 0 at 3 and 4.
Enumerated twice at two different page sizes (20 and 5), paging until empty; both give the
same totals with no duplicate identifiers.
size, so size=100&page=1 returns nothing at
all. Page until you get an empty result; do not compute offsets from size.
orgGroupDetail.name
All 43 entries are labelled 「政府部門」 (government department). That set includes
FamilyMart, President Chain Store (7-Eleven), Chunghwa Telecom, Taiwan Mobile, Far EasTone
and a number of private companies.
orgType 1/2 appears to be the issuer/verifier
distinction instead — the Highway Bureau appears in both, convenience stores only in 2, and
convenience stores are the verifier in the telecom-VC parcel-pickup scenario.
0x84172caf8dd126c76f1fa8a2733ca3233264d31f. The
anchoring transaction for the Ministry of Digital Affairs carries 2,596 bytes of calldata
containing the complete DID string and the complete self-signed DID document JWS in
plaintext. sha256(DID) does not appear.
frontend.wallet.gov.tw.
orgType) with an empty
onChainHistory, x509_type: XCA, and — although it is registered
under the issuer orgType — issuerMetadataBaseURL: null.
The credential path is anchored on a public chain. The revocation path is a URL.
iss did:key → fails
key-2 from the jku URL → verifies
GET https://issuer-vc.wallet.gov.tw/api/keys returns two keys.
key-1 is the one embedded in the issuer's DID and signs credentials.
key-2 signs status lists and appears in no DID document, no trust list
entry, and no on-chain record. The DID document has exactly one verification method.
nbf → exp is exactly 86,400 seconds. But nbf is
when the list was last signed, not when you fetched it.
exp the list carries; never tell a user
"valid for 24 hours".
exp is an absolute time.
Two complete driving-licence credentials, verified end to end. What separates "over 18" from "under 18" is one disclosure — and there is no way to send it without sending the birthday.
_sd digests (6/6, both credentials), and both ES256 signatures verify against
key-1 fetched live from the issuer's JWKS.
name, id_number,
type, controlnumber and gDate carry the same values in
both; only roc_birthday differs — 1040605 (ROC 104 → 2015-06-05,
under 18) against 0570605 (ROC 057 → 1968-06-05, over 18). Different salts, so
different digests, but the same document otherwise.
age_over_18 boolean, nothing derived. The only way to answer
「are you over 18」 with this credential is to disclose roc_birthday
in full, to the day.
_sd carries exactly six digests, and the credential configuration that defines
those six fields is published in the issuer metadata for anybody to read. So a verifier who
receives four disclosures knows two were withheld, and knows the six-item menu they were
drawn from.
credentialStatus.statusListIndex is per-credential: #20 on one of
these, #19 on the other. It is not selectively disclosable — it sits in the
signed payload, outside _sd, so it goes to every verifier every time.
Found by building the card list, rendering it, and looking at it. The row read
00000000_demo_drivinglicense_202504251418.
vc.type[1] is the sole field a wallet can call the card by, and it is an
issuing-system identifier: forty-one characters, wrapping over two lines on a 390pt screen.
The human-readable name lives in the issuer's metadata, at
credential_configurations_supported[…].display[].name — an HTTPS
endpoint (measured: 701 KB, 882 configurations).
CardInventory.readableType drops the two segments that are meaningless to a
holder — a leading all-digit issuer code and a trailing 8–14 digit issuance timestamp — and
shows the rest. The row above becomes demo_drivinglicense.
demo_drivinglicense is still the issuing system's word, not the holder's. The
fix belongs at the issuer, which is why it is item 8 below.
Ordered by how much a user is harmed, not by technical severity. Numbers 6 and 7 are held back pending better evidence, and are listed here so the reason is visible rather than silent.
String decoded = utf8.decode(base64.decode(base64Url)); if (decoded.contains(field)) { … }
id_number and controlnumber, and
both contain the substring number. A value containing another field's name
matches too; a random salt can match by chance. Separately, a continue sits in
the inner loop, so a disclosure matching two requested fields is appended twice.
setAllowedHostnames(null). In HttpUtils that does not mean "no
allowlist, use the system default" — the custom HostnameVerifier
returns true unconditionally. The binding between the TLS
certificate and the hostname is gone on those four connections.
jku to be restricted to an allowlist of trusted URLs.
validateVC() launches six steps as concurrent futures; fetching the status list
(step 3) and the schema (step 5) start before the issuer's signature is checked (step 6),
and both URLs come out of the not-yet-verified payload. Combined with finding 2, anyone who
can POST a VP can make the verifier issue a GET to an arbitrary URL.
StatusListCheckTask has no DID-first
attempt — it goes straight to loadIssuerPublicKey(jku, kid). This is the one
place where the jku pattern is genuinely live in production, and it is
self-referential: the list names the key that verifies the list.
client_id carries no information, and two checks are switched off
See §1. The ID Token signature is never verified; AccessTokenFilter's validation is commented out.
format does not describe the credential
882 of 882. See §3.
context, not @context
All three VP-generating functions in the wallet SDK write 'context' without the
at-sign. Searching for the VCDM context URL finds only those three, so
no path in the system emits the correct key.
vc.type[1] is an internal identifier; the display name exists only in
online metadata and is not covered by any signature.
vc.name, or SD-JWT VC's vct together with signed type metadata —
so that an offline wallet can show a name its holder recognises.
did:key — the key is the identity. So rotating a
signing key means becoming a different issuer, and every credential already issued has an
iss that is no longer in the trust list. A wallet cannot distinguish "this
issuer was removed" from "this issuer rotated and the old cards are still genuine".
Listed because a page of findings without this section is asking to be over-read.
moda_dw client_id is accepted.