<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Santiago Cortés Fernández</title>
    <description>The latest articles on DEV Community by Santiago Cortés Fernández (@scf-12).</description>
    <link>https://dev.to/scf-12</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4057473%2Fdb31a5e3-ea8c-4072-9784-7c3523648459.jpg</url>
      <title>DEV Community: Santiago Cortés Fernández</title>
      <link>https://dev.to/scf-12</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scf-12"/>
    <language>en</language>
    <item>
      <title>Building a Custom MFA and Secure Session Handoff Platform for Shared In-Store Devices</title>
      <dc:creator>Santiago Cortés Fernández</dc:creator>
      <pubDate>Sat, 01 Aug 2026 06:51:43 +0000</pubDate>
      <link>https://dev.to/scf-12/secure-cross-device-mfa-platform-aws-3mf</link>
      <guid>https://dev.to/scf-12/secure-cross-device-mfa-platform-aws-3mf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This article describes an anonymized enterprise implementation. Company names, internal domains, repository identifiers, ticket numbers, and proprietary control names have been intentionally removed or generalized.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Multi-factor authentication is often described as a login problem: enter a password, receive a code, confirm identity.&lt;/p&gt;

&lt;p&gt;That model was not enough for the system described in this case study.&lt;/p&gt;

&lt;p&gt;The product ran in an in-store environment where the same tablet could be used by several people during a transaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an employee initiating the process;&lt;/li&gt;
&lt;li&gt;a manager approving or supporting it;&lt;/li&gt;
&lt;li&gt;a customer reviewing and signing on their own device.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The challenge was not simply to prove that a user knew a six-digit code. We needed to create a secure, short-lived handoff between a shared in-store session and the customer’s personal phone, without leaking the downstream signing session or allowing multiple devices to claim the same transaction.&lt;/p&gt;

&lt;p&gt;This post explains the architecture, the security model, the trade-offs, and the production practices behind that platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem: secure device handoff
&lt;/h2&gt;

&lt;p&gt;The workflow started on a shared tablet. At a certain point, the customer needed to continue part of the process on their own phone.&lt;/p&gt;

&lt;p&gt;The platform therefore had to answer several questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How does the phone prove that it belongs to the customer currently standing in front of the employee?&lt;/li&gt;
&lt;li&gt;How does the shared tablet know that the correct phone claimed the correct session?&lt;/li&gt;
&lt;li&gt;What happens if the QR code is scanned twice?&lt;/li&gt;
&lt;li&gt;How do we prevent session identifiers and tokens from appearing in URLs, browser history, logs, or referrer headers?&lt;/li&gt;
&lt;li&gt;How do we notify the phone immediately when verification succeeds?&lt;/li&gt;
&lt;li&gt;How do we ensure that a single-use signing URL is never exposed before verification?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those constraints turned a seemingly small MFA feature into a distributed-system problem involving identity, real-time communication, concurrency, edge delivery, infrastructure, and operational controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  High-level architecture
&lt;/h2&gt;

&lt;p&gt;The solution used two main application components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a React and TypeScript single-page application for the customer’s phone;&lt;/li&gt;
&lt;li&gt;a serverless GraphQL API built with AWS AppSync, Lambda, and DynamoDB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure also included CloudFront, S3, API Gateway, Route 53, ACM, WAF, Terraform, centralized logging, APM, synthetic monitoring, and automated deployment workflows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────┐
│ Shared in-store tablet      │
│ Employee / manager workflow │
└──────────────┬──────────────┘
               │ create handoff session
               ▼
┌─────────────────────────────┐
│ GraphQL API                 │
│ AppSync + Lambda            │
└──────────────┬──────────────┘
               │ persist state
               ▼
┌─────────────────────────────┐
│ DynamoDB                    │
│ session + short-link data   │
└─────────────────────────────┘

Shared tablet displays QR
               │
               ▼
┌─────────────────────────────┐
│ Customer phone              │
│ React SPA via CloudFront    │
└──────────────┬──────────────┘
               │ claim + subscribe
               ▼
┌─────────────────────────────┐
│ GraphQL API                 │
│ verification + status push  │
└─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The architecture was intentionally serverless. The workload was bursty, the user journey was short, and most operations were small state transitions rather than long-running computation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The end-to-end session-handoff flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create the handoff session
&lt;/h3&gt;

&lt;p&gt;The in-store application created a short-lived handoff record containing the transaction context, a downstream signing URL, expiration data, and a &lt;code&gt;PENDING&lt;/code&gt; status.&lt;/p&gt;

&lt;p&gt;The signing URL was treated as sensitive and single-use. It was stored internally but never returned to the customer-facing application while the handoff session remained pending.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Generate a short QR link
&lt;/h3&gt;

&lt;p&gt;The API created a short code associated with the handoff record. The in-store tablet rendered that short link as a QR code.&lt;/p&gt;

&lt;p&gt;The customer scanned it with their phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bootstrap the phone session securely
&lt;/h3&gt;

&lt;p&gt;The QR request passed through CloudFront and a small redirect endpoint.&lt;/p&gt;

&lt;p&gt;Instead of placing the session identifier and token in query parameters, the redirect returned short-lived cookies configured with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Secure&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SameSite=Strict&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a very short expiration time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The phone application read the values, configured its GraphQL client, and continued the session-handoff flow.&lt;/p&gt;

&lt;p&gt;This avoided exposing credentials through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;browser history;&lt;/li&gt;
&lt;li&gt;access logs;&lt;/li&gt;
&lt;li&gt;copied URLs;&lt;/li&gt;
&lt;li&gt;analytics tools;&lt;/li&gt;
&lt;li&gt;referrer headers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Claim the handoff session
&lt;/h3&gt;

&lt;p&gt;The phone called a &lt;code&gt;claimSession&lt;/code&gt; mutation.&lt;/p&gt;

&lt;p&gt;The backend generated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a six-digit security code;&lt;/li&gt;
&lt;li&gt;a unique session identifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code was displayed on the customer’s phone and read back to the employee using the shared tablet.&lt;/p&gt;

&lt;p&gt;The platform followed a &lt;strong&gt;last scanner wins&lt;/strong&gt; model. If another phone scanned the same QR code, a new claim superseded the previous one.&lt;/p&gt;

&lt;p&gt;This prevented two devices from holding the same active handoff session.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Verify the code
&lt;/h3&gt;

&lt;p&gt;The employee entered the six-digit code into the in-store application.&lt;/p&gt;

&lt;p&gt;The backend evaluated the verification as a state transition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;correct code: &lt;code&gt;PENDING → VERIFIED&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;incorrect code with attempts remaining: decrement attempts;&lt;/li&gt;
&lt;li&gt;final incorrect attempt: &lt;code&gt;PENDING → CANCELED&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;timeout: &lt;code&gt;PENDING → EXPIRED&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These transitions used DynamoDB conditional writes rather than read-modify-write sequences. The database became the concurrency primitive.&lt;/p&gt;

&lt;p&gt;That was important because two verification requests, or two competing claims, could not both succeed.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Push the result in real time
&lt;/h3&gt;

&lt;p&gt;The phone subscribed to handoff-status updates using GraphQL subscriptions.&lt;/p&gt;

&lt;p&gt;When verification succeeded, AppSync pushed the new state to the phone immediately.&lt;/p&gt;

&lt;p&gt;There was no polling loop.&lt;/p&gt;

&lt;p&gt;The phone then performed a final authenticated read. Only at that point did the API expose the single-use signing URL.&lt;/p&gt;

&lt;p&gt;The application redirected the customer to the signing experience and expired the handoff record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why GraphQL subscriptions instead of polling?
&lt;/h2&gt;

&lt;p&gt;Polling would have been easier to explain, but it would also have introduced unnecessary traffic, slower feedback, and more lifecycle edge cases.&lt;/p&gt;

&lt;p&gt;The product requirement was naturally event-driven:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Notify this phone when the in-store session verifies this exact handoff session.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GraphQL subscriptions provided:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;low-latency status changes;&lt;/li&gt;
&lt;li&gt;built-in filtering by handoff context;&lt;/li&gt;
&lt;li&gt;managed WebSocket reconnection through the client library;&lt;/li&gt;
&lt;li&gt;no custom polling interval or retry scheduler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend still handled one mobile-specific failure mode: operating systems frequently suspend browser tabs when users lock their phones or switch applications.&lt;/p&gt;

&lt;p&gt;When the tab became visible again, the application re-fetched the handoff state. This covered the case where the WebSocket had been paused and the final event was missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six-digit code served two purposes
&lt;/h2&gt;

&lt;p&gt;One of the most important design decisions was to use the six-digit value as both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the human-readable MFA challenge;&lt;/li&gt;
&lt;li&gt;the server-side capability required to access handoff state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code was required on reads and subscription filters. It was never returned to a superseded device, and the downstream signing URL remained hidden until the state reached &lt;code&gt;VERIFIED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This removed the need for a second opaque session token while preserving field-level authorization.&lt;/p&gt;

&lt;p&gt;It also reduced the number of credentials that had to be generated, stored, synchronized, and invalidated.&lt;/p&gt;

&lt;h2&gt;
  
  
  A layered security model
&lt;/h2&gt;

&lt;p&gt;The platform used defense in depth rather than treating the MFA code as the only control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge protection
&lt;/h3&gt;

&lt;p&gt;CloudFront and regional endpoints were protected by AWS WAF. Managed rule groups were introduced in stages, with higher-risk rules enforced first and false-positive-prone groups initially left in monitoring mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transport and identity
&lt;/h3&gt;

&lt;p&gt;All traffic used HTTPS. The GraphQL API used a custom Lambda authorizer that validated OAuth/JWT tokens, issuer, signature, expiration, operation-level scopes, and authorization weights.&lt;/p&gt;

&lt;p&gt;Unknown operations failed closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contract hardening
&lt;/h3&gt;

&lt;p&gt;The GraphQL surface disabled introspection in deployed environments and limited query depth and resolver count.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field-level authorization
&lt;/h3&gt;

&lt;p&gt;Resolvers checked the security code before returning handoff state.&lt;/p&gt;

&lt;p&gt;The signing URL was only included when the handoff session was verified.&lt;/p&gt;

&lt;p&gt;For superseded subscriptions, the resolver returned no data rather than revealing why the session had changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credential hygiene
&lt;/h3&gt;

&lt;p&gt;Sensitive handoff credentials were not passed in URLs. Logging excluded personally identifiable information and authentication secrets. Correlation identifiers were preserved for debugging without leaking session data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the frontend intentionally small
&lt;/h2&gt;

&lt;p&gt;The customer-facing application was a single-purpose flow, so the frontend avoided unnecessary framework complexity.&lt;/p&gt;

&lt;p&gt;It used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React;&lt;/li&gt;
&lt;li&gt;TypeScript in strict mode;&lt;/li&gt;
&lt;li&gt;Vite;&lt;/li&gt;
&lt;li&gt;React Context and local hooks;&lt;/li&gt;
&lt;li&gt;a managed GraphQL client;&lt;/li&gt;
&lt;li&gt;a shared enterprise design system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There was no router and no global state library.&lt;/p&gt;

&lt;p&gt;The application had only a few states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;loading or claiming;&lt;/li&gt;
&lt;li&gt;showing the MFA code;&lt;/li&gt;
&lt;li&gt;displaying an error or terminal status;&lt;/li&gt;
&lt;li&gt;redirecting after verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That simplicity reduced bundle size, attack surface, upgrade burden, and cognitive overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure as code from day one
&lt;/h2&gt;

&lt;p&gt;All infrastructure was defined in Terraform.&lt;/p&gt;

&lt;p&gt;The API repository provisioned not only the backend but also the shared infrastructure required by the frontend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AppSync;&lt;/li&gt;
&lt;li&gt;Lambda functions;&lt;/li&gt;
&lt;li&gt;DynamoDB;&lt;/li&gt;
&lt;li&gt;S3;&lt;/li&gt;
&lt;li&gt;CloudFront;&lt;/li&gt;
&lt;li&gt;API Gateway;&lt;/li&gt;
&lt;li&gt;DNS and certificates;&lt;/li&gt;
&lt;li&gt;WAF;&lt;/li&gt;
&lt;li&gt;monitoring resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend repository produced an immutable build artifact but did not own a separate infrastructure stack.&lt;/p&gt;

&lt;p&gt;This created a single source of truth for the platform topology while allowing the application and API to evolve independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build once, deploy many
&lt;/h2&gt;

&lt;p&gt;The frontend did not embed environment-specific configuration at build time.&lt;/p&gt;

&lt;p&gt;Instead, it derived the environment from the deployed hostname. The same artifact could therefore be promoted unchanged through development, QA, pre-production, and production.&lt;/p&gt;

&lt;p&gt;That pattern provided two benefits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;production received the exact artifact previously tested;&lt;/li&gt;
&lt;li&gt;configuration remained a deployment concern rather than a build concern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The delivery pipeline included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;linting and automated tests;&lt;/li&gt;
&lt;li&gt;static analysis;&lt;/li&gt;
&lt;li&gt;infrastructure validation;&lt;/li&gt;
&lt;li&gt;security scanning;&lt;/li&gt;
&lt;li&gt;semantic versioning;&lt;/li&gt;
&lt;li&gt;immutable artifact publishing;&lt;/li&gt;
&lt;li&gt;environment approvals;&lt;/li&gt;
&lt;li&gt;production change-management integration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Observability was part of the product
&lt;/h2&gt;

&lt;p&gt;Operational visibility was not added after launch.&lt;/p&gt;

&lt;p&gt;The platform included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured JSON logs;&lt;/li&gt;
&lt;li&gt;correlation identifiers;&lt;/li&gt;
&lt;li&gt;named business events;&lt;/li&gt;
&lt;li&gt;centralized log forwarding;&lt;/li&gt;
&lt;li&gt;APM instrumentation;&lt;/li&gt;
&lt;li&gt;dashboards for Lambda, GraphQL, and transaction health;&lt;/li&gt;
&lt;li&gt;latency, error-rate, and throttle alerts;&lt;/li&gt;
&lt;li&gt;synthetic health checks;&lt;/li&gt;
&lt;li&gt;environment-specific escalation rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The monitoring configuration lived in an independent Terraform state so it could be changed without modifying the main application infrastructure.&lt;/p&gt;

&lt;p&gt;This separation was useful because alerting often evolves at a different pace than application resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real trade-off: compliance over the cheaper API option
&lt;/h2&gt;

&lt;p&gt;One of the more instructive decisions involved API Gateway.&lt;/p&gt;

&lt;p&gt;The first implementation used the newer HTTP API because it was simpler and cheaper. During compliance work, the team confirmed that the required WAF association was not supported for that API type.&lt;/p&gt;

&lt;p&gt;The available choices were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep the newer API and request an exception;&lt;/li&gt;
&lt;li&gt;redesign the edge flow;&lt;/li&gt;
&lt;li&gt;move to REST API v1, which supported the required WAF attachment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform moved to REST API v1.&lt;/p&gt;

&lt;p&gt;That increased the per-request cost, but observed traffic was low enough that the monthly impact was negligible. The security and compliance benefit was more important than the small infrastructure savings.&lt;/p&gt;

&lt;p&gt;The lesson was not that REST API v1 is always better. The lesson was to evaluate cloud-service choices against the full production constraint set, not only developer convenience or headline pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery in vertical slices
&lt;/h2&gt;

&lt;p&gt;The product moved from an empty repository to production through incremental phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;foundational API, database, and Terraform modules;&lt;/li&gt;
&lt;li&gt;edge delivery, DNS, certificates, and the first mobile UI;&lt;/li&gt;
&lt;li&gt;custom authorization and scoped access;&lt;/li&gt;
&lt;li&gt;expiration, short links, QR redirects, and secure session bootstrap;&lt;/li&gt;
&lt;li&gt;exclusive claiming and real-time subscriptions;&lt;/li&gt;
&lt;li&gt;structured logging, dashboards, alerts, and synthetic monitoring;&lt;/li&gt;
&lt;li&gt;WAF enforcement and compliance rollout;&lt;/li&gt;
&lt;li&gt;production automation and lifecycle maintenance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each phase delivered a complete architectural capability rather than a disconnected set of files.&lt;/p&gt;

&lt;p&gt;This made integration risks visible earlier and allowed the design to evolve as platform constraints were discovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patterns worth reusing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Use conditional writes for small distributed state machines
&lt;/h3&gt;

&lt;p&gt;For workflows with simple transitions, DynamoDB condition expressions can replace application locks and read-before-write logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treat rejected designs as valuable documentation
&lt;/h3&gt;

&lt;p&gt;Several implementation paths failed because of platform constraints. Recording why they failed prevented future engineers from repeating the same experiments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push authorization as close to the data as practical
&lt;/h3&gt;

&lt;p&gt;Field-level checks in resolvers ensured that both queries and subscriptions followed the same rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ship security controls gradually
&lt;/h3&gt;

&lt;p&gt;The WAF configuration was introduced in monitoring mode, analyzed, then selectively enforced with rollback flags and a production canary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep single-purpose frontends single-purpose
&lt;/h3&gt;

&lt;p&gt;The absence of a router or complex state library was a deliberate architectural choice, not missing sophistication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build production readiness into the initial architecture
&lt;/h3&gt;

&lt;p&gt;Authentication, CI/CD, observability, security scanning, health checks, and rollback mechanisms should not be postponed until after feature completion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;The hardest part of this platform was not generating a six-digit code.&lt;/p&gt;

&lt;p&gt;The real engineering challenge was creating a trustworthy handoff between a shared device and a customer-owned device while handling concurrency, real-time status, short-lived credentials, single-use downstream sessions, production security, and enterprise delivery controls.&lt;/p&gt;

&lt;p&gt;A custom MFA flow becomes valuable when it is treated as a complete distributed system rather than a small authentication widget.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>authentication</category>
      <category>security</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
