How to troubleshoot the OID4VCI Authorization Code flow
Work from a symptom to the analytic event that explains it, using the identifiers you already have.
This guide covers symptoms specific to the OID4VCI Authorization Code flow. It assumes you know which events the flow emits, as described in Monitoring the OID4VCI Authorization Code flow.
For the general method, which covers locating events by request ID, reading data.error, and what to
send our support team, refer to
How to monitor a flow.
An issuance spans a dozen separate exchanges, so the last event that fired is usually enough to tell you which one broke. Some requests are also rejected before MATTR VII records them, so the absence of an event does not mean the attempt never happened. Refer to when there is no event.
Because the offer in this flow is reusable and carries no holder identity, start most investigations
from the userId rather than the offer. Refer to
finding the attempt for one holder.
The holder never came back from authentication
What you will find: OPENID_AUTHORIZE_START and
OPENID_AUTHENTICATION_PROVIDER_AUTHORIZE_START, and then nothing.
The holder was redirected to your authentication provider and MATTR VII never heard from them again. This is the most common place for an attempt to end, and the events cannot tell you why, because everything that happened next happened on your identity provider. Silence after the redirect means the holder never got back to MATTR VII at all. The usual causes are the holder abandoning the login, closing the browser, or a provider misconfiguration that stops the callback from reaching MATTR VII.
The fix: check your authentication provider logs for the same window. Confirm the callback URL configured at your provider still matches the one MATTR VII expects, as described in Configure an Authentication provider.
The reliable confirmation is the absence of OPENID_AUTHENTICATION_PROVIDER_AUTHORIZE_SUCCESS, which
MATTR VII emits when the login interaction ends, whether the holder was new or returning.
OPENID_AUTHORIZE_SUCCESS is the stricter test, because it only fires once the whole authorization
phase has completed and an authorization code has been issued.
Confirm the holder is matched on their data.userId rather than on a user event. A returning holder is
matched on their existing linked account, so they produce USER_UPDATE_SUCCESS rather than
USER_CREATE_SUCCESS.
The holder came back but was turned away
What you will find: OPENID_AUTHENTICATION_PROVIDER_AUTHORIZE_FAIL, followed by
OPENID_AUTHORIZE_FAIL.
A holder who fails authentication at your provider, or who declines the request there, is still
redirected back to MATTR VII, and that return is recorded. The OAuth error your provider returned
becomes data.error.type and its error_description becomes data.error.message, so your provider's
own reason is preserved. OPENID_AUTHORIZE_FAIL follows as the authorization phase is terminated, and
the holder is returned to the wallet with an error.
The same event also covers a holder who authenticated successfully but whose claims MATTR VII could
not then retrieve from your provider, so read data.error before concluding the holder was rejected.
The fix: start from data.error. A rejection at your provider is resolved at your provider. A
claim retrieval failure points instead at the token or userinfo endpoint MATTR VII called, and at the
scopes configured on the authentication provider.
Issuance fails on the first attempt but succeeds on retry
What you will find: OPENID_AUTHORIZE_FAIL, reporting that the interaction session could not be
found, on an attempt that the holder then repeats successfully within seconds.
One likely cause is browser anti-tracking behavior rather than a MATTR VII failure. The authorization phase involves several cross-site redirects, and browser protections such as Intelligent Tracking Prevention on iOS and bounce tracking mitigations in Chromium can strip the cookies MATTR VII relies on to resume the interaction. Where that happens the session is still valid on the server, but the returning request cannot be matched to it.
This cause is most likely on holders who run the same flow repeatedly, because repeated cross-site
redirects to the same domains are exactly what these protections are designed to detect. A single
holder seeing it once, with no repeat traffic behind it, is more likely a genuinely expired
interaction, so check the elapsed time between OPENID_AUTHORIZE_START and the failure before
settling on this explanation.
The fix: refer to Issuance fails on the first attempt but succeeds on retry for the full description of the behavior.
Your interaction hook rejected the holder
What you will find: OPENID_INTERACTION_HOOK_START, then OPENID_INTERACTION_HOOK_FAIL.
Two different situations produce this event, and data.error.message tells them apart:
- Your hook deliberately turned the holder away. When your component returns an
errorobject in its response JWT, MATTR VII terminates the session and copies yourerror.messageintoOPENID_INTERACTION_HOOK_FAIL. The holder is redirected back to the wallet with anaccess_deniederror. This is the expected outcome for a failed biometric or liveness check. Refer to Handling errors. - MATTR VII could not accept your hook's response. A response JWT signed with the wrong secret, an
expired
exp, a mismatchedissoraud, or a missing or incorrectstateall fail validation.
The fix: for a deliberate rejection, none is needed. The message you see is the one your own
component supplied, so make it specific enough to be useful here, but do not put personal information
in it. For a validation failure, check the signing secret, the claims in the response JWT, and that
you are echoing the state from the session token unchanged.
The holder completed your hook but never returned
What you will find: OPENID_INTERACTION_HOOK_START, and then nothing.
MATTR VII handed the holder to your component and no callback arrived. The interaction is happening entirely on your side, so nothing is recorded until the holder is redirected back.
Two causes account for most cases:
- The holder abandoned the interaction, or took longer than the session allowed. Once the interaction session expires, the holder is shown an error on their return rather than being let through.
- Your component never redirected, for example because it could not verify the incoming
session_tokenand rejected the request.
The fix: the two causes need different checks.
- For an abandoned or slow interaction, compare
sessionTimeoutInSecon your interaction hook configuration against how long the interaction actually takes. The minimum is 300 seconds and the maximum is 7200. Raising it helps a holder who is legitimately slow. It does nothing for one who walked away. - For a component that never redirected, its own logs are the only record. Confirm it verified the
incoming
session_token, and that it then redirected to theredirectUrlclaim from that token.
The token exchange was rejected
What you will find: OPENID_TOKEN_FAIL.
The wallet presented an authorization code that MATTR VII would not exchange. Authorization codes are single use and short lived, and the exchange is bound to the values used in the original authorization request, so the usual causes are:
- The code was already redeemed. A wallet that retries a token exchange it has already completed gets a rejection on the second attempt.
- The code expired before the wallet used it.
- The PKCE verifier does not match the
code_challengesent on the authorization request. - The
redirect_uridoes not match the one sent on the authorization request.
OPENID_TOKEN_FAIL carries only data.error, data.sessionId, and data.userId, so the values that
were rejected are not on the failure event itself. Read them from the two START events instead, and
compare data.redirect_uri and data.client_id on OPENID_TOKEN_START against the same two fields on
the OPENID_AUTHORIZE_START that began the attempt.
Pairing those two events takes some care. OPENID_AUTHORIZE_START carries neither a sessionId nor a
userId, so there is no identifier shared with the token events. Pair them on data.client_id and on
the request timestamps, working back from the failure. A PKCE mismatch cannot be confirmed this way at
all, because the code_verifier the wallet sent is removed from OPENID_TOKEN_START before storage,
so it shows up only as the error message on OPENID_TOKEN_FAIL.
The fix: the holder must restart the flow from the offer. Codes cannot be reissued.
The wallet obtained an access token but the credential request failed
What you will find: OPENID_TOKEN_SUCCESS, then nothing at all, not even
OPENID_CREDENTIAL_START.
The usual causes here are all rejected in front of the credential handler, so none of them reaches
OPENID_CREDENTIAL_FAIL. A missing OPENID_CREDENTIAL_START is the signal that the request was turned
away this early, and the HTTP response the wallet received is what tells the causes apart:
- The access token expired or was invalid. MATTR VII responds
400with{"error": "invalid_token"}. Comparedata.expires_inonOPENID_TOKEN_SUCCESS, and that event's timestamp, against when the holder says the wallet requested the credential. - The token scope does not cover the requested credential. MATTR VII responds
400with{"error": "insufficient_scope"}. The scope is fixed at the start of the flow, on the authorization request, so comparedata.scopeonOPENID_AUTHORIZE_STARTandOPENID_TOKEN_SUCCESSagainst the credential configuration the wallet then asked for. - The wallet named a credential configuration your tenant does not have. MATTR VII responds
400with{"error": "unknown_credential_configuration"}.
The fix: for expiry, have the wallet complete the exchange promptly. For scope, check that the scope the wallet requests matches a credential configuration included in the offer. Because none of these reach the events, the response the wallet received is the primary evidence, so ask the holder or the wallet vendor for it.
Your claims source did not respond usably
What you will find: OPENID_CLAIM_SOURCE_RETRIEVE_START, then
OPENID_CLAIM_SOURCE_RETRIEVE_FAIL, then OPENID_CREDENTIAL_FAIL on the same request ID.
Issuance depends on those claims, so a claims source that does not return usable data ends the whole credential request. The two sides of the exchange see different things, and you need both to work out what happened:
- What MATTR VII records.
OPENID_CLAIM_SOURCE_RETRIEVE_FAILputs the reason indata.error.type, which distinguishes MATTR VII being unable to reach your claims source at all, your claims source responding with a non-success status, and your claims source responding with something that is not a JSON object. The event also carriesclaimSourceId, andOPENID_CLAIM_SOURCE_RETRIEVE_SUCCESScarriesclaimSource.urlandclaimSource.requestMethod, so you can confirm which endpoint MATTR VII called. - What the wallet receives. MATTR VII responds to the credential request with
503and the body{"error": "service_unavailable"}, not an OAuth rejection. The holder sees a service error rather than being turned away. A claim that cannot be mapped returns400with{"error": "invalid_credential_request"}instead, so a holder reporting a service error points at the claims source rather than at your claim mappings.
The outbound call to your claims source carries the same x-request-id as the credential request that
triggered it. If you log that header on your claims source, you can match your own server logs to the
MATTR VII events directly.
The fix: check your claims source availability and its response format. If it uses OAuth, look
also for OPENID_CLAIM_SOURCE_AUTHORIZATION_FAIL.
Claims came back but the credential could not be built
What you will find: OPENID_CREDENTIAL_FAIL, with a message naming a claim that could not be
resolved.
In this flow the claims used to build a credential arrive from up to three places: the authentication provider, the interaction hook, and the claims source. All three are removed from the events before storage, so you cannot read the values that were actually used, only confirm that each step ran.
That makes the authentication provider the first thing to check, because it is the only one of the three where a claim can go missing without any failure being recorded. A provider only returns the claims covered by the scopes MATTR VII requests, so a claim that is absent from the requested scopes is silently absent from the credential too. Refer to Ensuring claim availability.
The fix: compare the claims each source supplies against the claimMappings in your
credential configuration, and confirm the
authentication provider scopes cover every claim you map from it.
A certificate or validity period problem
What you will find: OPENID_CREDENTIAL_FAIL with a data.error.type of
InvalidCredentialRequestPayload and the message Failed to create mobile credential. The wallet
receives 400 with {"error": "invalid_credential_request"}.
That generic message is what separates this from a claim problem. A claim that could not be resolved
names the claim, so a bare Failed to create mobile credential points at the certificates MATTR VII
needed rather than at your data. The specific certificate reason is not carried on the event.
The causes fall into two groups:
- No usable certificate. No active IACA covered the requested credential validity, or there was no valid document signer under it.
- A validity period that cannot be satisfied. The credential would outlive the document signer, or falls outside the validity of any active IACA.
The fix: check certificate management. Confirm your IACA
is active and that its validity window covers the validUntil you are asking for. If the
certificates check out, send us the request ID, as described in
How to monitor a flow.
The same holder ended up with two user records
What you will find: USER_CREATE_SUCCESS on an attempt where you expected
USER_UPDATE_SUCCESS, and existingUser set to false on
OPENID_AUTHENTICATION_PROVIDER_AUTHORIZE_SUCCESS.
MATTR VII matches a returning holder on their subjectId at your authentication provider. A holder
whose subjectId changes, for example because they signed in through a different provider or your
provider issues a new identifier, does not match the existing record, so a second user is created and
the credentials issued earlier stay attached to the first one.
The fix: confirm your authentication provider issues a stable subject identifier per person. Refer to User creation by issuance workflow.
The credential was issued but the webhook never arrived
What you will find: OPENID_CREDENTIAL_SUCCESS, and then either WEBHOOK_EVENT_PROCESS_FAIL or
no webhook events at all.
WEBHOOK_EVENT_PROCESS_FAILmeans MATTR VII tried and your endpoint rejected the delivery or was unreachable. Retries appear as furtherWEBHOOK_EVENT_PROCESS_STARTandWEBHOOK_EVENT_PROCESS_FAILpairs rather than as a retry count on a single event.- No webhook events at all means the webhook was skipped. A disabled or deleted webhook produces no event whatsoever, so confirm the webhook still exists and is enabled.
Refer to Webhooks for delivery behavior and signature validation.
Nothing appears for the attempt
Work through these in order:
- Check the step. The
.well-knownmetadata fetch, the wallet reading the offer, the holder authenticating, and the holder completing your interaction hook all produce no events. Several redirect hops inside the authorization phase reach MATTR VII and emit nothing either. Refer to what you can observe. - Check what you are searching on. The offer carries no identity in this flow, so an offer-based
search finds nothing. Resolve the holder to a
userIdfirst. - Check the time window and the tenant. Confirm you are querying the tenant that issued the offer, and that your window is in UTC.
- Check whether the request could have been rejected before it was recorded. Refer to when there is no event.
Next steps
How would you rate this page?
Last updated on