EUDIW Trust Framework: QTSP Certificates & Trust Anchors — Developer Guide 2026
Post #4 in the sota.io EU Digital Identity & eIDAS 2.0 Developer Series
Post #1 covered EUDIW Relying Party architecture. Post #2 walked through OpenID4VP cross-device flows. Post #3 deep-dived into SD-JWT selective disclosure. This post answers the question every Relying Party developer eventually asks: how do I know the credential was issued by someone I should trust?
The answer lives in the EUDIW trust framework — a three-tier hierarchy grounded in Regulation (EU) 2024/1183, national supervisory bodies, and the EU Trusted List. Getting certificate validation right is the difference between a compliant integration and a system that either rejects valid EU wallets or (worse) accepts spoofed credentials.
The Problem: Who Issued This Credential?
When a wallet presents a VP Token to your Relying Party backend, you receive an SD-JWT with an iss (issuer) claim pointing to a URL, and a certificate chain embedded in the JWT header's x5c field. At that moment, you need to answer three questions:
- Is the issuer a qualified trust service? Only QTSPs (Qualified Trust Service Providers) can issue credentials that carry legal weight under eIDAS 2.0.
- Is the issuer's certificate currently valid? Certificates expire; QTSPs can be suspended or have their qualified status revoked.
- Is the issuer on the EU Trusted List? Member States publish machine-readable lists of their certified QTSPs. Your verification logic must check this list — not just certificate cryptography.
Standard TLS certificate validation using browser CAs is not sufficient. You need QTSP-aware trust anchor validation rooted in the EU Trusted List.
The Trust Hierarchy Under eIDAS 2.0
The eIDAS 2.0 trust architecture has four tiers:
EU Commission
│ Publishes the EU Trusted List of Trusted Lists (LOTL)
│ ec.europa.eu/tools/lotl/eu-lotl.xml
▼
Member State Supervisory Body (e.g. BSI for DE, ANSSI for FR)
│ Art. 17 — designated national supervisory body
│ Art. 20 — supervises QTSPs, maintains national Trusted List
▼
Qualified Trust Service Provider (QTSP)
│ Art. 24 — strict identity verification, key ceremonies, HSM requirements
│ Art. 45d — can issue Qualified Electronic Attestation of Attributes (QEAA)
│ For EUDIW: issues PID (Person Identification Data) and mDL credentials
▼
EU Digital Identity Wallet (certified per Art. 5c)
│ Holds QEAA credentials in secure enclave
▼
End User → Selective presentation to Relying Parties (Art. 5b)
Every level has legal obligations. The EU Commission operates the List of Trusted Lists (LOTL) — a meta-list pointing to each Member State's national Trusted List. Your code starts at the LOTL and walks down to verify any credential.
EU Trusted Lists: Art. 22 in Practice
Article 22 of Regulation (EU) No 910/2014 (as amended by 2024/1183) requires each Member State to establish, maintain, and publish a Trusted List of qualified trust service providers and the qualified trust services they provide. The European Commission aggregates these into the LOTL.
LOTL Entry Point
https://ec.europa.eu/tools/lotl/eu-lotl.xml
This XML file lists pointers to each national Trusted List. The structure follows the ETSI TS 119 612 standard. Each entry contains a URL to the national TSL, a sequence number, and a signing certificate for verifying the national list's integrity.
National TSL Structure
Each national TSL (e.g., Germany: https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/DigitialeSignaturen/VTL.xml) contains entries for every QTSP operating in that Member State. Each QTSP entry includes:
- Service Type — the OID indicating what kind of qualified service (e.g.,
http://uri.etsi.org/TrstSvc/Svctype/CA/QCfor a CA issuing qualified certificates) - Service Current Status —
http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/grantedorwithdrawn - Service Digital Identity — the X.509 certificate chain for the service
- StatusStartingTime — when this status took effect
A Relying Party must check that the issuer certificate appears in a national TSL with status granted, and that this status was granted at the time the credential was issued (not just now).
QTSP Requirements: What Art. 24 Demands
Article 24 sets the requirements that turn a Trust Service Provider into a Qualified Trust Service Provider. Understanding these requirements helps you appreciate why QTSP-issued credentials carry stronger legal weight than non-qualified alternatives.
Key Art. 24 obligations:
Identity Verification — Before issuing any qualified certificate or QEAA, the QTSP must verify the identity of the applicant. For natural persons: face-to-face or equivalent identity proofing. For legal persons: verification against official registers. This verification must be recorded and retained.
Key Ceremonies and HSM Requirements — QTSP private keys must be generated and stored in Hardware Security Modules that meet FIPS 140-2 Level 3 or equivalent. Key generation must follow documented ceremonies with witnesses. The private key must never exist in plaintext outside the HSM.
Termination Plan — QTSPs must maintain a documented termination plan describing how they will transfer or terminate services without harm to existing trust anchors. This is why Relying Parties don't need to worry about sudden disappearance of a QTSP — the supervisory body (Art. 20) steps in.
Record Retention — Qualified certificates and their issuance records must be retained for at least 35 years after expiry or revocation. This supports long-term validation of digital signatures on legal documents.
Audit — QTSPs are subject to conformity assessment by accredited Conformity Assessment Bodies (CABs) at least every 24 months. The CAB audit covers security policies, key ceremonies, personnel vetting, and incident response.
Qualified Electronic Attestation of Attributes (QEAA): Art. 45d
The most important new trust service category introduced by eIDAS 2.0 for EUDIW is Qualified Electronic Attestation of Attributes (QEAA), defined in Article 45d.
A QEAA is a signed attestation from a QTSP confirming that a specific attribute of a person or organisation is accurate. In the EUDIW context:
- PID (Person Identification Data) — government-issued attestation of name, date of birth, nationality, address. Issued by the Member State's designated QTSP (often directly by the government body).
- mDL (mobile Driving Licence) — ISO 18013-5 compliant driving licence credential issued as a QEAA.
- Professional qualifications — attestations of academic degrees, professional certifications, occupational licences.
- eHealth data — prescribed medication, disability status (under health-specific frameworks).
QEAA Legal Status (Art. 45b)
Article 45b establishes the non-discrimination principle for electronic attestation of attributes: Member States must ensure that attestations "issued by or on behalf of a public sector body" have the same legal validity as officially certified paper documents. QEAAs carry a presumption of accuracy for all claimed attributes.
QEAA Certificate OID
When you receive a credential in a VP Token, you can identify it as a QEAA by looking for the following OID in the issuer certificate's Extended Key Usage (EKU) extension:
id-etsi-qcs-QcType QEAA
OID: 0.4.0.1862.1.6.3
This is part of the ETSI EN 319 412 series (qualified certificate profiles). A certificate with this OID in the EKU is a certificate issued to a QTSP specifically authorised to issue QEAAs — the type of certificate that signs EUDIW wallet credentials.
TypeScript: QTSP Certificate Validation for Relying Parties
Here is a complete TypeScript implementation for validating EUDIW issuer credentials against the EU Trusted List.
Fetch and Cache the EU LOTL
import * as xml2js from 'xml2js';
import { X509Certificate } from 'crypto';
const LOTL_URL = 'https://ec.europa.eu/tools/lotl/eu-lotl.xml';
const TSL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
interface TrustedService {
serviceType: string;
serviceName: string;
status: 'granted' | 'withdrawn' | 'deprecated' | 'recognisedatnationallevel';
statusStartingTime: Date;
certificates: X509Certificate[];
}
interface NationalTsl {
countryCode: string;
services: TrustedService[];
fetchedAt: Date;
}
let cachedTsls: Map<string, NationalTsl> = new Map();
let lotlFetchedAt: Date | null = null;
async function fetchLotlPointers(): Promise<Array<{country: string; url: string}>> {
const response = await fetch(LOTL_URL);
const xmlText = await response.text();
const parsed = await xml2js.parseStringPromise(xmlText, { explicitArray: true });
// LOTL structure: TrustServiceStatusList/SchemeInformation/PointersToOtherTSL
const pointers = parsed?.TrustServiceStatusList?.SchemeInformation?.[0]
?.PointersToOtherTSL?.[0]?.OtherTSLPointer ?? [];
return pointers.map((p: any) => ({
country: p.AdditionalInformation?.[0]?.OtherInformation?.[0]
?.SchemeTerritory?.[0] ?? 'unknown',
url: p.TSLLocation?.[0] ?? '',
})).filter((p: any) => p.url);
}
async function fetchNationalTsl(countryCode: string, url: string): Promise<NationalTsl> {
const response = await fetch(url);
const xmlText = await response.text();
const parsed = await xml2js.parseStringPromise(xmlText, { explicitArray: true });
const providers = parsed?.TrustServiceStatusList
?.TrustServiceProviderList?.[0]
?.TrustServiceProvider ?? [];
const services: TrustedService[] = [];
for (const provider of providers) {
const tspServices = provider.TSPServices?.[0]?.TSPService ?? [];
for (const svc of tspServices) {
const info = svc.ServiceInformation?.[0];
if (!info) continue;
const serviceType = info.ServiceTypeIdentifier?.[0] ?? '';
const statusUri = info.ServiceStatus?.[0] ?? '';
const statusTime = info.StatusStartingTime?.[0];
// Extract X.509 certificate(s) from ServiceDigitalIdentity
const certValues = info.ServiceDigitalIdentity?.[0]
?.DigitalId?.map((d: any) => d.X509Certificate?.[0])
.filter(Boolean) ?? [];
const certificates = certValues.map((b64: string) => {
try {
const der = Buffer.from(b64, 'base64');
return new X509Certificate(der);
} catch {
return null;
}
}).filter(Boolean) as X509Certificate[];
const status = statusUri.includes('/granted') ? 'granted'
: statusUri.includes('/withdrawn') ? 'withdrawn'
: statusUri.includes('/deprecated') ? 'deprecated'
: 'recognisedatnationallevel';
services.push({
serviceType,
serviceName: info.ServiceName?.[0]?.Name?.[0]?._ ?? 'unknown',
status,
statusStartingTime: new Date(statusTime),
certificates,
});
}
}
return { countryCode, services, fetchedAt: new Date() };
}
export async function loadTrustAnchors(): Promise<void> {
const pointers = await fetchLotlPointers();
const results = await Promise.allSettled(
pointers.map(p => fetchNationalTsl(p.country, p.url)
.then(tsl => cachedTsls.set(p.country, tsl)))
);
lotlFetchedAt = new Date();
const failed = results.filter(r => r.status === 'rejected').length;
if (failed > 0) console.warn(`[EUDIW] Failed to fetch ${failed}/${pointers.length} national TSLs`);
}
Validate Issuer Certificate Against Trusted List
const QTSP_SERVICE_TYPES = [
'http://uri.etsi.org/TrstSvc/Svctype/CA/QC', // CA issuing qualified certs
'http://uri.etsi.org/TrstSvc/Svctype/IdV/nothingID', // Identity validation
'http://uri.etsi.org/TrstSvc/Svctype/EAA', // Electronic attestation of attributes (EUDIW specific)
];
interface TslValidationResult {
trusted: boolean;
countryCode?: string;
serviceName?: string;
serviceType?: string;
statusAtIssuance?: 'granted' | 'withdrawn' | 'deprecated' | 'recognisedatnationallevel';
error?: string;
}
export function validateIssuerAgainstTsl(
issuerCert: X509Certificate,
credentialIssuedAt: Date,
): TslValidationResult {
for (const [country, tsl] of cachedTsls.entries()) {
for (const service of tsl.services) {
// Check if this service is a QTSP type relevant for EUDIW credentials
const isRelevantType = QTSP_SERVICE_TYPES.some(t => service.serviceType.startsWith(t))
|| service.serviceType.includes('EAA')
|| service.serviceType.includes('QC');
if (!isRelevantType) continue;
// Check if any of the service's certificates matches the issuer cert
const match = service.certificates.some(tslCert => {
try {
// Match by SubjectPublicKeyInfo (SPKI) fingerprint
return tslCert.fingerprint256 === issuerCert.fingerprint256
|| issuerCert.issuer === tslCert.subject;
} catch {
return false;
}
});
if (!match) continue;
// Found a match — check status at time of credential issuance
// A service that was 'granted' at issuance is valid even if later withdrawn
const statusAtIssuance = service.status === 'granted'
&& service.statusStartingTime <= credentialIssuedAt
? 'granted'
: service.status;
return {
trusted: statusAtIssuance === 'granted',
countryCode: country,
serviceName: service.serviceName,
serviceType: service.serviceType,
statusAtIssuance,
};
}
}
return {
trusted: false,
error: 'Issuer certificate not found in any national Trusted List',
};
}
Full Credential Verification Pipeline
import { importJWK, jwtVerify, decodeProtectedHeader } from 'jose';
interface VerifiedCredential {
claims: Record<string, unknown>;
issuer: string;
qtspCountry: string;
qtspServiceName: string;
validAt: Date;
}
export async function verifyEudiwCredential(
sdJwtPresentation: string,
issuanceDate: Date,
): Promise<VerifiedCredential> {
// Split SD-JWT: <issuer-jwt>~<disclosure1>~...~<kb-jwt>
const parts = sdJwtPresentation.split('~');
const issuerJwt = parts[0];
const disclosures = parts.slice(1, -1); // middle parts
const kbJwt = parts[parts.length - 1];
// 1. Decode header to get x5c (certificate chain)
const header = decodeProtectedHeader(issuerJwt);
if (!header.x5c || !Array.isArray(header.x5c) || header.x5c.length === 0) {
throw new Error('Missing x5c certificate chain in credential header');
}
// 2. Parse the leaf certificate (first in x5c)
const leafCertDer = Buffer.from(header.x5c[0], 'base64');
const leafCert = new X509Certificate(leafCertDer);
// 3. Validate certificate chain integrity
for (let i = 1; i < header.x5c.length; i++) {
const parentDer = Buffer.from(header.x5c[i], 'base64');
const parentCert = new X509Certificate(parentDer);
if (!leafCert.checkIssued(parentCert)) {
throw new Error(`Certificate chain broken at position ${i}`);
}
}
// 4. Validate leaf cert against EU Trusted List
const tslResult = validateIssuerAgainstTsl(leafCert, issuanceDate);
if (!tslResult.trusted) {
throw new Error(`Issuer not trusted: ${tslResult.error}`);
}
// 5. Extract the issuer's public key for JWT signature verification
const publicKey = leafCert.publicKey;
const jwk = JSON.parse(publicKey.export({ format: 'jwk' }) as string);
const verifyKey = await importJWK(jwk);
// 6. Verify the issuer-JWT signature and standard claims
const { payload } = await jwtVerify(issuerJwt, verifyKey, {
requiredClaims: ['iss', 'iat', 'vct'],
});
// 7. Reconstruct disclosed claims from SD-JWT disclosures
const claims: Record<string, unknown> = { ...payload };
const sdHashes = (payload._sd as string[]) ?? [];
for (const disclosure of disclosures) {
const disclosureJson = Buffer.from(disclosure, 'base64url').toString();
const [_salt, claimName, claimValue] = JSON.parse(disclosureJson);
// Verify the disclosure hash appears in _sd (proves issuer committed to it)
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update(disclosure).digest('base64url');
if (sdHashes.includes(hash)) {
claims[claimName] = claimValue;
}
}
// Clean up internal SD fields from the returned claims
delete claims._sd;
delete claims._sd_alg;
return {
claims,
issuer: payload.iss as string,
qtspCountry: tslResult.countryCode!,
qtspServiceName: tslResult.serviceName!,
validAt: new Date((payload.iat as number) * 1000),
};
}
QTSP Certificate OIDs: What to Check
Beyond TSL lookup, you can inspect the credential issuer's X.509 certificate for EUDIW-specific OIDs. The ETSI EN 319 412-5 standard defines:
| OID | Meaning |
|---|---|
0.4.0.1862.1.1 | id-etsi-qcs-QcCompliance — certificate is qualified |
0.4.0.1862.1.4 | id-etsi-qcs-QcSSCD — private key is in a Secure Signature Creation Device |
0.4.0.1862.1.6.1 | QcType: esign (for natural persons) |
0.4.0.1862.1.6.2 | QcType: eseal (for legal persons / organisations) |
0.4.0.1862.1.6.3 | QcType: web — QWAC (website authentication) |
0.4.0.1862.1.6.4 | QcType: EAA — Electronic Attestation of Attributes (EUDIW credentials) |
For EUDIW credential issuers, expect OIDs 0.4.0.1862.1.1 (QcCompliance) and 0.4.0.1862.1.6.4 (QcType EAA). A certificate without QcCompliance is not a qualified certificate — it is a regular TLS or signing certificate and cannot anchor EUDIW trust.
TypeScript: Check QTSP OIDs
const QTSP_REQUIRED_OIDS = [
'0.4.0.1862.1.1', // QcCompliance
'0.4.0.1862.1.6.4', // QcType EAA (for EUDIW credential issuers)
];
function hasQtspOids(cert: X509Certificate): boolean {
try {
// The certifiate extensions include the OIDs in the QcStatements extension
// OID 1.3.6.1.5.5.7.1.3 = id-pe-qcStatements
const extensions = cert.extensions;
const qcStmts = extensions?.find(ext => ext.oid === '1.3.6.1.5.5.7.1.3');
if (!qcStmts) return false;
const rawValue = qcStmts.value.toString('hex');
return QTSP_REQUIRED_OIDS.every(oid =>
rawValue.includes(oid.split('.').map(n =>
parseInt(n).toString(16).padStart(2, '0')
).join(''))
);
} catch {
return false;
}
}
Handling QTSP Revocation
QTSPs can have their qualified status withdrawn for non-compliance. Under Art. 20, the national supervisory body can remove a QTSP from the Trusted List. When this happens:
- The QTSP is moved from
grantedtowithdrawnstatus in the national TSL - The
StatusStartingTimerecords exactly when the withdrawal took effect - Credentials issued before the withdrawal date remain valid (the QTSP was qualified at time of issuance)
- Credentials issued after the withdrawal date should be rejected
Your validation logic must compare the credential's iat (issuance timestamp) against the TSL StatusStartingTime — not just check the current status. The code above handles this correctly via service.statusStartingTime <= credentialIssuedAt.
Additionally, individual certificates (not just QTSP status) can be revoked. Check OCSP/CRL:
async function checkCertificateRevocation(cert: X509Certificate): Promise<boolean> {
const ocspUrl = extractOcspUrl(cert); // from Authority Information Access extension
if (!ocspUrl) {
console.warn('[EUDIW] No OCSP URL in certificate — falling back to CRL');
return checkCrl(cert);
}
const ocspRequest = buildOcspRequest(cert);
const response = await fetch(ocspUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/ocsp-request' },
body: ocspRequest,
});
if (!response.ok) throw new Error(`OCSP check failed: ${response.status}`);
const ocspResponse = await response.arrayBuffer();
return parseOcspResponse(Buffer.from(ocspResponse)); // true = not revoked
}
Caching Strategy for Production
Fetching the full EU LOTL (27+ national TSLs) on every credential verification request is not practical. The ETSI specification provides nextUpdate fields in each TSL; national lists are updated at most every 3 months. A practical production caching strategy:
| Cache Layer | TTL | Contents |
|---|---|---|
| In-memory | 24h | Parsed TSL data as Map<country, services> |
| Redis | 7 days | Serialised TSL XML per country |
| CDN/Object Storage | 30 days | Backup LOTL snapshot for fallback |
Refresh logic: on application startup, load from Redis (if fresh) or re-fetch. Schedule a background job to refresh every 12 hours. On cache miss during verification (e.g. brand-new QTSP not in memory), trigger an immediate TSL refresh for the relevant country.
EUDIW Issuer Metadata Discovery
Beyond certificate validation, EUDIW issuers must publish OpenID4VCI issuer metadata at a well-known endpoint. Your Relying Party should also fetch and validate this metadata:
GET https://{issuer}/.well-known/openid-credential-issuer
The response includes:
credential_issuer— must match theissclaim in credentialscredential_endpoint— where wallets request credentialscredentials_supported— list of credential types with their format and claimsjwks_uri— JSON Web Key Set for signature verification
For EUDIW specifically, you should cross-check the issuer URL against the TSL ServiceDigitalIdentity. A legitimate EUDIW issuer will have a matching entry in the national Trusted List.
Trust Hierarchy Validation Checklist
Implement all five checks before accepting a credential:
| Step | Check | Rejection Message |
|---|---|---|
| 1 | x5c present in JWT header | missing_certificate_chain |
| 2 | Certificate chain valid (each cert issued by next) | invalid_certificate_chain |
| 3 | Leaf cert in national TSL with status granted at issuance time | issuer_not_trusted |
| 4 | Leaf cert has QcCompliance OID (0.4.0.1862.1.1) | not_qualified_certificate |
| 5 | Certificate not revoked (OCSP/CRL) | certificate_revoked |
| 6 | Issuer metadata matches TSL entry | issuer_metadata_mismatch |
| 7 | SD-JWT issuer signature verifies | invalid_signature |
| 8 | KB-JWT holder binding verifies | invalid_holder_binding |
Steps 1-6 are the trust framework checks (this post). Steps 7-8 are the cryptographic checks covered in Post #3.
Key Deadlines and Rollout Timeline
Understanding the timeline helps you prioritise what to build now vs. what is still stabilising:
May 2024 — Regulation (EU) 2024/1183 published in OJ; eIDAS 2.0 enters into force.
November 2024 — Deadline for the EU Commission to publish implementing acts defining technical specifications for EUDIW (wallet certification criteria, ARF reference, LOTL updates).
End 2026 — Member States must provide operational EU Digital Identity Wallets to all citizens and residents requesting one. Mandatory acceptance by large online platforms, public services, financial institutions, and transport operators.
Now (2026) — Pilot EUDIW implementations live in DE, IT, FR, SE and several others. QTSPs issuing prototype QEAA credentials. ARF v2.4.0 (January 2026) is the current canonical reference.
Your integration should be testable against pilot wallets today. The national Trusted Lists already include pilot QTSP entries — your validation code can run against real pilot data in staging.
What's Next in This Series
Post #5 (finale) will cover EUDIW in Practice: End-to-End Integration Testing, Staging Environments, and Launch Checklist — connecting everything from Posts #1-4 into a production-ready integration guide with real pilot wallet endpoints, common error codes, and the 20-item go-live checklist for Relying Parties.
Summary
The EUDIW trust framework is built on Regulation (EU) 2024/1183 and the existing eIDAS Trusted List infrastructure. As a Relying Party developer:
- Ground all trust in the EU LOTL — fetch and cache national Trusted Lists; validate every credential issuer against them.
- Check QTSP status at issuance time — a QTSP withdrawn after credential issuance does not invalidate previously issued credentials.
- Verify QTSP OIDs — look for
QcComplianceandQcType EAAin the X.509 certificate's QcStatements extension. - Implement OCSP/CRL checks — certificate validity is separate from TSL status; check both.
- Cache aggressively — national TSLs update at most quarterly; cache at 24h in memory + 7 days in Redis.
The legal weight of EUDIW credentials flows directly from this trust chain. Get it right and you're relying on government-certified identity data with legal equivalence to paper documents. Skip it and you're trusting self-signed JWTs.
EU-Native Hosting
Ready to move to EU-sovereign infrastructure?
sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.