Created
September 18, 2026 15:10
-
-
Save chibuezefelix/9da74fdb41b867e9e2930eaff7b14023 to your computer and use it in GitHub Desktop.
The ECI Flag and 3D Secure in a Spring Boot Application
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| The structural goal is that the raw two digit string exists in exactly one place in your codebase, and everything downstream works with a typed value. String comparison of ECI codes scattered across services is how the brand confusion described above gets into production. | |
| Start with the two enums. | |
| package com.example.payments.threeds; | |
| public enum CardBrand { | |
| VISA, | |
| MASTERCARD, | |
| AMERICAN_EXPRESS, | |
| DISCOVER, | |
| JCB | |
| } | |
| package com.example.payments.threeds; | |
| public enum AuthenticationOutcome { | |
| FULLY_AUTHENTICATED, | |
| ATTEMPTED, | |
| NOT_AUTHENTICATED | |
| } | |
| Now the single translation point. This class is the only place in the application that knows which digits belong to which brand. | |
| package com.example.payments.threeds; | |
| import java.util.Map; | |
| import java.util.Optional; | |
| public final class EciResolver { | |
| private static final Map<String, AuthenticationOutcome> MASTERCARD_CODES = Map.of( | |
| "02", AuthenticationOutcome.FULLY_AUTHENTICATED, | |
| "01", AuthenticationOutcome.ATTEMPTED, | |
| "00", AuthenticationOutcome.NOT_AUTHENTICATED | |
| ); | |
| private static final Map<String, AuthenticationOutcome> STANDARD_CODES = Map.of( | |
| "05", AuthenticationOutcome.FULLY_AUTHENTICATED, | |
| "06", AuthenticationOutcome.ATTEMPTED, | |
| "07", AuthenticationOutcome.NOT_AUTHENTICATED | |
| ); | |
| private EciResolver() { | |
| } | |
| public static Optional<AuthenticationOutcome> resolve(CardBrand brand, String rawEci) { | |
| if (brand == null || rawEci == null || rawEci.isBlank()) { | |
| return Optional.empty(); | |
| } | |
| Map<String, AuthenticationOutcome> codes = | |
| brand == CardBrand.MASTERCARD ? MASTERCARD_CODES : STANDARD_CODES; | |
| return Optional.ofNullable(codes.get(rawEci.trim())); | |
| } | |
| } | |
| The method returns an Optional rather than throwing, because a code that does not belong to the brand is a real situation you will meet, and it deserves a decision rather than a stack trace. Returning empty covers both "this brand never uses that code" and "the field was missing". | |
| Next, the data you receive. A record gives you immutability and a constructor for free, and bean validation annotations work on record components in Spring Boot 3. | |
| package com.example.payments.threeds; | |
| import jakarta.validation.constraints.NotBlank; | |
| import jakarta.validation.constraints.Pattern; | |
| import jakarta.validation.constraints.Size; | |
| public record ThreeDSecureResult( | |
| @NotBlank(message = "transactionReference is required") | |
| String transactionReference, | |
| @Pattern(regexp = "^(0[0-2]|0[5-7])$", | |
| message = "eci must be one of 00, 01, 02, 05, 06, 07") | |
| String eci, | |
| @Size(max = 64, message = "authenticationValue exceeds the accepted length") | |
| String authenticationValue, | |
| @Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", | |
| message = "dsTransactionId must be a UUID") | |
| String dsTransactionId, | |
| @Pattern(regexp = "^2\\.[0-9]+\\.[0-9]+$", | |
| message = "protocolVersion must be a 3D Secure 2 version string") | |
| String protocolVersion | |
| ) { | |
| } | |
| The pattern ^(0[0-2]|0[5-7])$ accepts 00, 01, 02, 05, 06 and 07 and rejects everything else. Compare that with ^(0[0-7])$, which you will find in a lot of sample code. That version also accepts 03 and 04, neither of which is a defined value here, so it lets malformed input through while looking like validation. | |
| Two details about this record are deliberate. The length constraint on the authentication value is a sanity bound and not a format rule, because the cryptogram is opaque to you and its encoding is the issuer's business. And @Pattern treats a null value as valid, which is what you want, since an absent ECI is a legitimate state that the service layer needs to see rather than a validation failure. | |
| Now the decision. A boolean would compress four distinct situations into two, so the service returns a sealed type instead. | |
| package com.example.payments.threeds; | |
| public sealed interface AuthenticationAssessment { | |
| record Authenticated(AuthenticationOutcome outcome, String eci) | |
| implements AuthenticationAssessment {} | |
| record NotAuthenticated(AuthenticationOutcome outcome, String eci) | |
| implements AuthenticationAssessment {} | |
| record NoAuthenticationPresent() | |
| implements AuthenticationAssessment {} | |
| record Unusable(String reason) | |
| implements AuthenticationAssessment {} | |
| } | |
| package com.example.payments.threeds; | |
| import org.springframework.stereotype.Service; | |
| @Service | |
| public class ThreeDSecureAssessmentService { | |
| public AuthenticationAssessment assess(CardBrand brand, ThreeDSecureResult result) { | |
| if (result == null || result.eci() == null || result.eci().isBlank()) { | |
| return new AuthenticationAssessment.NoAuthenticationPresent(); | |
| } | |
| String eci = result.eci().trim(); | |
| return EciResolver.resolve(brand, eci) | |
| .map(outcome -> classify(eci, outcome, result)) | |
| .orElseGet(() -> new AuthenticationAssessment.Unusable( | |
| "ECI " + eci + " is not defined for " + brand)); | |
| } | |
| private AuthenticationAssessment classify(String eci, | |
| AuthenticationOutcome outcome, | |
| ThreeDSecureResult result) { | |
| if (outcome == AuthenticationOutcome.NOT_AUTHENTICATED) { | |
| return new AuthenticationAssessment.NotAuthenticated(outcome, eci); | |
| } | |
| if (result.authenticationValue() == null || result.authenticationValue().isBlank()) { | |
| return new AuthenticationAssessment.Unusable( | |
| "ECI " + eci + " indicates authentication but no authentication value was supplied"); | |
| } | |
| return new AuthenticationAssessment.Authenticated(outcome, eci); | |
| } | |
| } | |
| The Unusable branch is the one that earns its keep. A Visa transaction carrying 02, or an ECI of 05 arriving with no cryptogram, are both integration defects rather than business outcomes, and you want them surfaced as such so somebody investigates. Because the interface is sealed, a switch over the four cases elsewhere in your code will fail to compile if you add a fifth state and forget to handle it. | |
| A parameterized test pins the brand behaviour down. | |
| package com.example.payments.threeds; | |
| import org.junit.jupiter.params.ParameterizedTest; | |
| import org.junit.jupiter.params.provider.CsvSource; | |
| import static org.junit.jupiter.api.Assertions.assertEquals; | |
| class ThreeDSecureAssessmentServiceTest { | |
| private final ThreeDSecureAssessmentService service = new ThreeDSecureAssessmentService(); | |
| @ParameterizedTest | |
| @CsvSource({ | |
| "VISA, 05, SAMPLECAVV, Authenticated", | |
| "VISA, 06, SAMPLECAVV, Authenticated", | |
| "VISA, 07, , NotAuthenticated", | |
| "MASTERCARD, 02, SAMPLECAVV, Authenticated", | |
| "MASTERCARD, 01, SAMPLECAVV, Authenticated", | |
| "MASTERCARD, 00, , NotAuthenticated", | |
| "VISA, 02, SAMPLECAVV, Unusable", | |
| "MASTERCARD, 05, SAMPLECAVV, Unusable", | |
| "VISA, 03, SAMPLECAVV, Unusable", | |
| "VISA, 05, , Unusable", | |
| "VISA, , , NoAuthenticationPresent" | |
| }) | |
| void assessesEachBrandAndCode(CardBrand brand, | |
| String eci, | |
| String authenticationValue, | |
| String expectedState) { | |
| ThreeDSecureResult result = new ThreeDSecureResult( | |
| "TXN1001", | |
| eci, | |
| authenticationValue, | |
| "9f2c1b4e-7a3d-4c5e-8b1a-2d3e4f5a6b7c", | |
| "2.2.0"); | |
| assertEquals(expectedState, service.assess(brand, result).getClass().getSimpleName()); | |
| } | |
| } | |
| An empty field in @CsvSource arrives as null, which is how the missing cryptogram and missing ECI rows work. Asserting on the simple class name keeps the table compact; if you prefer, give the sealed interface a kind() method and assert on that instead. | |
| Operational details engineers get wrong | |
| Persist the ECI, the authentication value, the directory server transaction identifier and the protocol version against the transaction record, at the moment you receive them. A chargeback can arrive months after the sale, and the dispute response asks for exactly these fields. Your gateway may or may not still hold them, and you cannot reconstruct any of them after the fact. Storing them costs you four columns. | |
| Never log the card number. Treat the authentication value as sensitive too: keep it out of log lines, out of exception messages and out of anything you return to the client. It is a single use cryptogram, so leaking it is less severe than leaking a PAN, but it is authentication material and it belongs in the same category in your logging configuration |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment