2026-05-31·5 min read·sota.io Team

EUDIW End-to-End Integration Testing: Staging Environments & Go-Live Checklist 2026

Post #5 (Finale) in the sota.io EU Digital Identity & eIDAS 2.0 Developer Series

EUDIW end-to-end integration testing staging environments and go-live checklist 2026

This is the finale of our five-part EUDIW developer series. Post #1 covered Relying Party architecture. Post #2 walked through OpenID4VP cross-device flows. Post #3 explained SD-JWT selective disclosure. Post #4 mapped the QTSP trust hierarchy. This post closes the loop: how do you test your integration end-to-end, and what does your go-live checklist look like?

Production-readiness for EUDIW is different from typical OAuth integrations. You have legal obligations under Regulation (EU) 2024/1183, a trust framework that requires certificate-level validation, and a nascent pilot ecosystem where test tooling is still stabilising. Getting this right before launch saves weeks of post-production firefighting.


The EUDIW Test Ecosystem in 2026

The eIDAS 2.0 pilot infrastructure has three tiers, each offering different fidelity:

Tier 1: LSP (Large-Scale Pilot) Test Environments

The three EU-funded Large-Scale Pilots — EWC (EUDIW Wallet Consortium), NOBID (Banking/Government), and POTENTIAL (Cross-Border Public Services) — each run dedicated test environments with real protocol implementations. EWC's staging environment is the most mature for private-sector Relying Parties.

EWC Staging Endpoints (as of Q2 2026):

The LSP environments use real cryptography but test-only trust anchors. Credentials issued here are not legally valid — but the protocol flows are production-identical.

Tier 2: ARF-Compliant Reference Implementations

The EU Commission's eIDAS Expert Group publishes reference implementations alongside the Architecture Reference Framework (ARF). These include:

These are the canonical source of truth when you encounter protocol ambiguity. If your implementation disagrees with the reference, your implementation is wrong.

Tier 3: Protocol-Level Mocks

For local development and CI/CD, you need mocks that don't require network access to LSP endpoints:

// Mock EUDIW Wallet for integration tests
export class MockEUDIWallet {
  private testPID: SignedCredential;
  private testMDL: SignedCredential;

  constructor(private config: WalletConfig) {
    this.testPID = loadTestCredential("pid-test-vector-v1.json");
    this.testMDL = loadTestCredential("mdl-test-vector-v1.json");
  }

  async respondToAuthorizationRequest(
    request: AuthorizationRequest
  ): Promise<AuthorizationResponse> {
    const requestedFields = parseRequestedFields(request);

    // Select matching test credential
    const credential = requestedFields.docType === "org.iso.18013.5.1.mDL"
      ? this.testMDL
      : this.testPID;

    // Apply selective disclosure according to request
    const vp = await buildVPToken(credential, requestedFields, this.config.signingKey);

    return {
      vp_token: vp,
      presentation_submission: buildPresentationSubmission(requestedFields),
      state: request.state,
    };
  }
}

Setting Up Your Staging Environment

A well-structured staging environment mirrors production in protocol behaviour while using test-only keys and credentials. Here is the recommended setup:

Separate Issuer DID + Keys

Never share keys between staging and production. Your staging deployment needs its own:

# Generate staging issuer key pair (P-256 for ECDSA-SD)
openssl ecparam -name prime256v1 -genkey -noout -out staging-issuer-private.pem
openssl ec -in staging-issuer-private.pem -pubout -out staging-issuer-public.pem

# Generate DID:JWK for staging
node scripts/did-jwk-from-pem.js staging-issuer-public.pem
# → did:jwk:eyJjcnYiOiJQLTI1NiIsImtpZCI6InN0YWdpbmctaXNzdWVyLTAxIiwia3R5Ijoi...

Your staging openid-credential-issuer metadata should point to staging endpoints only:

{
  "credential_issuer": "https://staging-issuer.example.com",
  "credential_endpoint": "https://staging-issuer.example.com/credentials",
  "authorization_servers": ["https://staging-auth.example.com"],
  "credential_configurations_supported": {
    "eu.europa.ec.eudi.pid.1": {
      "format": "vc+sd-jwt",
      "cryptographic_binding_methods_supported": ["did:jwk", "jwk"],
      "proof_types_supported": {
        "jwt": {
          "proof_signing_alg_values_supported": ["ES256"]
        }
      }
    }
  }
}

Test Credential Types

The ARF defines two primary credential types for testing:

PID (Person Identification Data): Minimum dataset — family_name, given_name, birth_date, age_over_18, resident_country, nationalities, issuance_date, expiry_date.

mDL (Mobile Driving Licence, ISO 18013-5): Driving privileges, portrait, signature. Most complex credential type due to mdoc CBOR encoding.

For staging, issue test PIDs with clearly fake data (e.g., given_name: "Test-User-EU"). Never issue staging credentials with real PII — a staging database breach should be low-risk by design.


Common Integration Errors

These are the integration failures that show up in virtually every Relying Party implementation. Fix these before production:

1. redirect_uri Mismatch

Symptom: Wallet returns invalid_request: redirect_uri mismatch during the OpenID4VP authorization request.

Root cause: Your Relying Party sent a redirect_uri that was not pre-registered or doesn't exactly match (including trailing slashes, scheme capitalisation).

// WRONG: runtime-constructed URL with unpredictable path
const redirectUri = `${window.location.origin}/callback/${sessionId}`;

// RIGHT: static, pre-registered URI; pass session state via `state` param
const redirectUri = "https://rp.example.com/eudiw/callback";
const state = encodeSessionId(sessionId);

Register all redirect URIs in your authorization server configuration before any test run.

2. Nonce Correlation Failure

Symptom: VP Token verification succeeds cryptographically, but your nonce check fails — preventing the credential from being accepted.

Root cause: Nonce mismatch between the Authorization Request and the presented VP Token's nonce claim.

// Store nonce with session BEFORE sending the authorization request
async function initiatePresentation(): Promise<AuthorizationRequest> {
  const nonce = crypto.randomUUID();
  await sessionStore.set(`nonce:${sessionId}`, nonce, { ttl: 300 }); // 5-minute TTL

  return buildAuthorizationRequest({
    nonce,
    state: sessionId,
    presentationDefinition: PID_MINIMAL_PD,
  });
}

// Verify nonce in callback
async function processCallback(response: AuthorizationResponse): Promise<void> {
  const expectedNonce = await sessionStore.get(`nonce:${response.state}`);
  if (!expectedNonce) throw new Error("Session expired or nonce not found");

  const vpClaims = await verifyVPToken(response.vp_token);
  if (vpClaims.nonce !== expectedNonce) {
    throw new Error(`Nonce mismatch: expected ${expectedNonce}, got ${vpClaims.nonce}`);
  }

  await sessionStore.delete(`nonce:${response.state}`);
  // Process credential claims...
}

3. Trust Chain Validation Errors

Symptom: TrustChainError: issuer not found in EU Trusted List — even for credentials from legitimate LSP test issuers.

Root cause: You're validating against the production EU Trusted List (LOTL), but LSP test issuers only appear in pilot-specific trust lists.

// Configure trust anchors per environment
const TRUST_ANCHORS = {
  production: "https://ec.europa.eu/tools/lotl/eu-lotl.xml",
  staging: [
    "https://ec.europa.eu/tools/lotl/eu-lotl.xml",        // production anchors
    "https://ewc-pilot.example.eu/trust/ewc-pilot-tsl.xml", // EWC pilot
    "https://nobid.example.eu/trust/nobid-test-tsl.xml",    // NOBID pilot
  ],
};

// In staging, accept both production and pilot trust anchors
const trustListUrls = process.env.NODE_ENV === "production"
  ? [TRUST_ANCHORS.production]
  : TRUST_ANCHORS.staging;

4. SD-JWT Disclosure Ordering

Symptom: Server-side verification passes, but some disclosed fields show as null in your parsed claims despite being in the VP Token.

Root cause: You're processing disclosures in array order, but the spec requires hash-indexed lookup (each disclosure's hash must match a _sd entry in the JWT payload).

// WRONG: process disclosures in order
function parseDisclosures(sdJwt: string): Record<string, unknown> {
  const [jwt, ...disclosures] = sdJwt.split("~");
  const claims: Record<string, unknown> = {};
  disclosures.forEach((d, i) => {
    const [salt, name, value] = JSON.parse(atob(d));
    claims[name] = value; // BUG: ignores hash matching
  });
  return claims;
}

// RIGHT: hash-indexed lookup
async function parseDisclosures(sdJwt: string): Promise<Record<string, unknown>> {
  const parts = sdJwt.split("~").filter(Boolean);
  const jwt = parts[0];
  const disclosureParts = parts.slice(1);

  const payload = decodeJwtPayload(jwt);
  const sdHashes = new Set<string>(payload._sd || []);

  const claims: Record<string, unknown> = {};
  for (const disc of disclosureParts) {
    const hash = await sha256Base64url(disc);
    if (sdHashes.has(hash)) {
      const [, name, value] = JSON.parse(base64urlDecode(disc));
      claims[name] = value;
    }
  }
  return claims;
}

5. mdoc CBOR Encoding Errors

Symptom: mdoc credential (ISO 18013-5) parsing fails with unexpected CBOR tag or missing DeviceEngagement.

Root cause: mdoc uses CBOR tagged structures that require specialised parsing — standard JSON parsers cannot handle them.

import { decode as cborDecode, encode as cborEncode } from "cbor-x";

// mdoc DeviceResponse is a CBOR-encoded, CBOR-tagged binary
function parseMdocResponse(deviceResponse: Uint8Array): MdocClaims {
  const decoded = cborDecode(deviceResponse);

  // DeviceResponse structure (ISO 18013-5 §8.3.2)
  const documents = decoded.documents;
  if (!documents?.length) throw new Error("No documents in DeviceResponse");

  const doc = documents[0];
  const issuerSigned = doc.issuerSigned;
  const nameSpaces = issuerSigned.nameSpaces;

  // Extract claims from the org.iso.18013.5.1 namespace
  const drivingLicenceNS = nameSpaces.get("org.iso.18013.5.1") || [];
  const claims: Record<string, unknown> = {};

  for (const itemBytes of drivingLicenceNS) {
    // Each item is a CBOR-tagged #6.24 bytes wrapping the IssuerSignedItem
    const item = cborDecode(itemBytes.value);
    claims[item.elementIdentifier] = item.elementValue;
  }

  return claims as MdocClaims;
}

E2E Test Harness

A complete integration test suite should cover four scenarios before production:

Scenario 1: Happy Path (PID)

describe("EUDIW PID Presentation - Happy Path", () => {
  it("should complete full OpenID4VP flow and extract PID claims", async () => {
    // 1. Initiate presentation request
    const session = await rpClient.initiatePresentation({
      presentationDefinition: PID_MINIMAL_PD,
      redirectUri: TEST_CALLBACK_URI,
    });
    expect(session.requestUri).toMatch(/^openid4vp:\/\//);

    // 2. Wallet responds (using mock wallet in CI)
    const walletResponse = await mockWallet.respondTo(session.requestUri);
    expect(walletResponse.vp_token).toBeTruthy();

    // 3. Process callback
    const claims = await rpClient.processCallback({
      vp_token: walletResponse.vp_token,
      presentation_submission: walletResponse.presentation_submission,
      state: session.state,
    });

    // 4. Verify extracted claims
    expect(claims.family_name).toBe("Mustermann");
    expect(claims.given_name).toBe("Erika");
    expect(claims.age_over_18).toBe(true);
    expect(claims.resident_country).toBe("DE");
  });
});

Scenario 2: Expired Credential

it("should reject expired PID credential", async () => {
  const expiredMock = mockWallet.withExpiredCredential();
  const session = await rpClient.initiatePresentation({ presentationDefinition: PID_MINIMAL_PD });
  const walletResponse = await expiredMock.respondTo(session.requestUri);

  await expect(
    rpClient.processCallback({
      vp_token: walletResponse.vp_token,
      presentation_submission: walletResponse.presentation_submission,
      state: session.state,
    })
  ).rejects.toThrow("CredentialExpired");
});

Scenario 3: Revoked Credential

it("should reject revoked credential via Status List 2021", async () => {
  const revokedMock = mockWallet.withRevokedCredential("status-list-entry-42");

  // Mock the status list endpoint to return revoked status
  nock(STATUS_LIST_ISSUER).get("/status/1").reply(200, REVOKED_STATUS_LIST_JWT);

  const session = await rpClient.initiatePresentation({ presentationDefinition: PID_MINIMAL_PD });
  const walletResponse = await revokedMock.respondTo(session.requestUri);

  await expect(
    rpClient.processCallback({
      vp_token: walletResponse.vp_token,
      presentation_submission: walletResponse.presentation_submission,
      state: session.state,
    })
  ).rejects.toThrow("CredentialRevoked");
});

Scenario 4: Replay Attack Prevention

it("should reject replayed VP Token (nonce reuse)", async () => {
  const session = await rpClient.initiatePresentation({ presentationDefinition: PID_MINIMAL_PD });
  const walletResponse = await mockWallet.respondTo(session.requestUri);

  // First presentation succeeds
  await rpClient.processCallback({
    vp_token: walletResponse.vp_token,
    presentation_submission: walletResponse.presentation_submission,
    state: session.state,
  });

  // Replay with same VP Token should fail
  await expect(
    rpClient.processCallback({
      vp_token: walletResponse.vp_token,           // same token
      presentation_submission: walletResponse.presentation_submission,
      state: session.state,                         // same state
    })
  ).rejects.toThrow("NonceAlreadyUsed");
});

Security Pre-Launch Testing

Beyond protocol correctness, your EUDIW integration has specific security requirements:

Cross-Origin Isolation

Credential data received from the EUDIW callback must never be accessible to third-party JavaScript on your page:

// Callback handler — process in server-side route, never expose to client
// pages/api/eudiw/callback.ts (Next.js)
export async function POST(req: Request): Promise<Response> {
  const { vp_token, presentation_submission, state } = await req.json();

  // Verify and extract claims server-side
  const claims = await processVPToken(vp_token, presentation_submission, state);

  // Store server-side; return only a session token to client
  const sessionToken = await createAuthenticatedSession(claims);
  return Response.json({ sessionToken });
}

Request URI One-Time Use

Authorization request URIs (used in cross-device flow) must be single-use to prevent link sharing attacks:

class RequestUriStore {
  async create(authRequest: AuthorizationRequest): Promise<string> {
    const requestId = crypto.randomUUID();
    await redis.setEx(`eudiw:request:${requestId}`, 120, JSON.stringify(authRequest)); // 2 min TTL
    return `https://rp.example.com/eudiw/request/${requestId}`;
  }

  async consume(requestId: string): Promise<AuthorizationRequest> {
    const data = await redis.getdel(`eudiw:request:${requestId}`); // atomic get + delete
    if (!data) throw new Error("RequestNotFound: expired or already used");
    return JSON.parse(data);
  }
}

Nonce Uniqueness at Scale

Under load, naive nonce generation can produce collisions. Use cryptographically secure generation:

// Secure nonce: 128 bits entropy, URL-safe base64
function generateNonce(): string {
  return Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
}

// Rate-limit nonce creation per client to prevent enumeration
const nonceRateLimiter = new RateLimiter({
  windowMs: 60_000,
  max: 10, // max 10 presentations per minute per IP
});

The 20-Item EUDIW Go-Live Checklist

Work through this before switching any production traffic to your EUDIW integration:

Protocol Compliance (Items 1–6)

Trust Chain (Items 7–9)

Security (Items 10–13)

Error Handling (Items 14–16)

Monitoring (Items 17–19)

Final Check (Item 20)


Post-Launch Monitoring

Once in production, these four metrics determine EUDIW integration health:

1. Funnel Completion Rate

Presentation Initiations → QR Code Scanned → Wallet Responded → Verification Passed → Session Created

Track drop-off at each stage. A high drop-off at "Wallet Responded" often indicates a cross-device deep-link problem. High drop-off at "Verification Passed" points to trust chain or credential format issues.

2. Credential Format Distribution

As more wallet implementations reach users, track which credential formats you receive:

metrics.increment("eudiw.credential.format", {
  format: vpToken.includes("~") ? "sd-jwt" : "mdoc",
  docType: claims.docType,
  issuerCountry: extractIssuerCountry(vpToken),
});

Early data will skew heavily toward PID from the first few member states that deploy. Over 2026–2027 you will see mDL, QEAA, and eventually private-sector EAAs.

3. Trust Chain Fetch Availability

The EU Trusted List is a critical dependency. Track its availability separately:

async function fetchWithFallback(url: string, cache: Cache): Promise<TrustList> {
  try {
    const fresh = await fetch(url, { signal: AbortSignal.timeout(5000) });
    const tsl = parseTrustList(await fresh.text());
    await cache.set("tsl", tsl, { ttl: 86400 }); // 24h cache
    return tsl;
  } catch (err) {
    metrics.increment("eudiw.tsl.fetch_error", { url, error: err.message });
    const cached = await cache.get("tsl");
    if (!cached) throw new Error("TrustListUnavailable: no cache available");
    metrics.increment("eudiw.tsl.stale_hit");
    return cached;
  }
}

Acceptable stale window: 48 hours (QTSPs are typically revoked over days, not minutes).

4. ARF Version Tracking

The Architecture Reference Framework is versioned and updated. Breaking changes in ARF v2.x → v3.x will require code changes. Subscribe to the GitHub repository and add a check:

// Warn in logs if wallet presents a newer ARF version than you support
const SUPPORTED_ARF_VERSIONS = ["2.0", "2.1", "2.2"];

function checkARFVersion(vpToken: DecodedVPToken): void {
  const walletArfVersion = vpToken.header?.arfVersion;
  if (walletArfVersion && !SUPPORTED_ARF_VERSIONS.includes(walletArfVersion)) {
    logger.warn("EUDIW wallet using unsupported ARF version", {
      walletArfVersion,
      supported: SUPPORTED_ARF_VERSIONS,
    });
    metrics.increment("eudiw.arf.version_mismatch", { walletArfVersion });
  }
}

What Comes Next in the EU Digital Identity Landscape

The EUDIW rollout follows a staged timeline through 2026–2027. Based on the current pilot trajectory:

Q3 2026: First member states publish production EUDIW wallet apps alongside national Trusted Lists. Credential issuance limited to PID (government-issued identity data).

Q4 2026 – Q1 2027: mDL (mobile driving licence) and educational credentials added as QEAAs. Cross-border credential recognition operational across the first cluster of member states.

2027: Private-sector EAA issuers (banks, universities, professional bodies) begin joining the ecosystem. Relying Parties who built against the ARF now benefit from network effects.

Relying Parties who integrate correctly in 2026 — with proper trust chain validation, SD-JWT selective disclosure, and production-grade error handling — will be positioned to accept any ARF-compliant wallet credential from any EU member state. That is the competitive moat: EUDIW acceptance becomes a differentiator for EU-market SaaS products competing against US alternatives.


Series Summary: What We Built

Over five posts, we covered the complete EUDIW Relying Party integration stack:

PostTopicKey Takeaway
#1RP ArchitectureOpenID4VP + metadata + Presentation Exchange foundation
#2Cross-Device FlowQR code → deep-link → wallet response handling
#3SD-JWT DisclosureHash-indexed disclosure parsing, selective claim reveal
#4Trust FrameworkLOTL parsing, QTSP OID validation, OCSP/CRL
#5Integration TestingStaging environment, test harness, 20-item checklist

The full stack — from the OpenID4VP authorization request to QTSP trust chain validation — is now in your hands. The EUDIW ecosystem is moving fast; implementation teams that build to the ARF specification today are six to twelve months ahead of those waiting for the ecosystem to "stabilise."


Running on European Infrastructure

If you are integrating EUDIW into a product that targets EU users, consider that the trust and sovereignty story extends beyond credential validation. Your credential processing backend, session store, and PII derived from wallet claims all fall under GDPR jurisdiction. Running those services on EU-sovereign infrastructure — where you can demonstrate to your DPA that no US CLOUD Act subpoena could reach your data — is increasingly a procurement requirement for public-sector Relying Parties.

sota.io provides European-hosted deployment for teams who need to demonstrate data sovereignty alongside EUDIW compliance. No US-based infrastructure, no CLOUD Act exposure — just EU-controlled compute for your EU Digital Identity integration.


This concludes the sota.io EU Digital Identity & eIDAS 2.0 Developer Series. Implementation questions? Join the discussion in r/selfhosted or open an issue in the EWC repository.

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.