<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Devpath Traveler]]></title><description><![CDATA[This blog is about building software the right way. A blog about the tales of a traveler on the developing path.
Not just how to write code—but how to design systems that make sense, scale well, and don’t turn into technical debt later. You’ll find practical insights on architecture, real-world trade-offs, and clean implementation using modern tools.
Clear thinking. Real examples. Long-term mindset.]]></description><link>https://devpath-traveler.nguyenviettung.id.vn</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1764903550013/07b702d5-e5d2-4409-8ec4-37670bebea31.png</url><title>Devpath Traveler</title><link>https://devpath-traveler.nguyenviettung.id.vn</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 09 Apr 2026 11:54:59 GMT</lastBuildDate><atom:link href="https://devpath-traveler.nguyenviettung.id.vn/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Designing the BFF Contract: Request Aggregation & Client-Specific Shaping]]></title><description><![CDATA[In the previous article, we established when a BFF earns its overhead. This article assumes you have made that decision and are now facing the harder question: how do you actually design the thing wel]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/designing-the-bff-contract-request-aggregation-client-specific-shaping</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/designing-the-bff-contract-request-aggregation-client-specific-shaping</guid><category><![CDATA[bff]]></category><category><![CDATA[Backend for frontend]]></category><category><![CDATA[API Design]]></category><category><![CDATA[versioning]]></category><category><![CDATA[API contract]]></category><category><![CDATA[FrontendArchitecture]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[request batching]]></category><category><![CDATA[caching]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[composition]]></category><category><![CDATA[backend orchestration]]></category><category><![CDATA[payload design]]></category><category><![CDATA[contract-testing]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Wed, 08 Apr 2026 13:11:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/b2605984-0b8c-4f68-9c33-64f12da7612b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the previous article, we established when a BFF earns its overhead. This article assumes you have made that decision and are now facing the harder question: how do you actually design the thing well?</p>
<p>The BFF contract — the API surface your application depends on — is the most consequential design decision in this architecture. Get it right and you have a clean, stable interface that lets the frontend move independently of upstream service changes. Get it wrong and you have a new layer that amplifies the coupling problems you were trying to solve, with the added pleasure of owning the infrastructure.</p>
<p>This article covers the four design concerns that determine which outcome you get: what aggregation actually means in practice, how to shape responses for the client rather than the domain, how to version the contract without recreating a backend team's problems, and where the boundary between BFF logic and upstream logic must be drawn and defended.</p>
<p>The examples and architecture decisions throughout are drawn from a production implementation built for a Norwegian enterprise in the education sector. Where the original system cannot be described in full, concepts have been generalised to meet NDA obligations — but the engineering trade-offs, the failure modes, and the decisions that shaped the final design are real. We use VueJS and .NET Core as the example frameworks since it's based on the real production project.</p>
<hr />
<h2>Aggregation is not just parallel fetching</h2>
<p>The word "aggregation" in BFF descriptions usually conjures an image of parallel HTTP calls fanning out to multiple services and the results being merged before the response is returned. That is part of it. But treating aggregation as a mechanical fan-out pattern misses what makes it valuable — and what makes it dangerous.</p>
<p>Consider a dashboard screen in an education platform. The frontend needs: the authenticated user's profile and role, the list of courses they are enrolled in, the next three upcoming sessions, and a count of unread notifications. In a naïve aggregation implementation, the BFF fires four requests in parallel, waits for all four, and concatenates the results into a response object.</p>
<p>This works. It is also fragile in a way that becomes apparent under load.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/a436198c-0ec2-49f3-b8fb-abf22f5b0df6.png" alt="" style="display:block;margin:0 auto" />

<p>A more considered design asks: what are the actual dependency relationships between these data sets? The course list depends on the user's organisation ID, which comes from the user profile. The session list is scoped to the course IDs returned by the course list. The notification count is independent. This is not a fan-out — it is a directed graph with two sequential phases:</p>
<pre><code class="language-plaintext">Phase 1 (parallel):
  → GET /identity/users/{id}         → profile + orgId
  → GET /notifications/unread/count  → notificationCount

Phase 2 (parallel, depends on Phase 1):
  → GET /courses?orgId={orgId}       → courseIds
  
Phase 3 (depends on Phase 2):
  → GET /sessions?courseIds={...}&amp;limit=3 → upcomingSessions
</code></pre>
<p>Treating this as a flat parallel fan-out would require either fetching all courses before knowing the org ID (not possible), or making the frontend responsible for the sequencing (which defeats the point). The BFF owns this orchestration — it understands the dependency graph and executes it efficiently, shielding the frontend from the fact that it exists.</p>
<p>This has a practical implication for implementation. In .NET Core, <code>Task.WhenAll</code> handles the parallel phases, and the sequential phases chain naturally:</p>
<pre><code class="language-csharp">// Phase 1: independent fetches in parallel
var (profile, notificationCount) = await (
    _userService.GetProfileAsync(userId),
    _notificationService.GetUnreadCountAsync(userId)
).WhenBoth();

// Phase 2: depends on profile
var courses = await _courseService.GetByOrgAsync(profile.OrgId);

// Phase 3: depends on courses
var courseIds = courses.Select(c =&gt; c.Id).ToArray();
var upcomingSessions = await _sessionService.GetUpcomingAsync(courseIds, limit: 3);
</code></pre>
<p>The aggregation logic lives in the BFF. The frontend makes one request and receives one coherent response. The dependency graph is invisible to the client.</p>
<h3>When aggregation goes wrong</h3>
<p>Two aggregation anti-patterns appear consistently in production BFFs.</p>
<p><strong>The "God endpoint."</strong> A single endpoint that returns everything the application might ever need, used on multiple screens because it is convenient. The God endpoint inflates payload sizes, makes partial failure handling impossible, and couples unrelated features together. If a notification service outage should not take down the course list, they must not share an endpoint. Design endpoints around screen-level data contracts, not around service boundaries.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/4c50fe4c-236c-4fcc-992e-8b6c955b5090.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Cascading failure without isolation.</strong> If Phase 2 in the example above fails because the course service is down, a poorly designed BFF either returns a 500 (crashing the whole screen) or silently swallows the error (showing stale or empty data with no indication of the problem). The correct design is explicit partial failure handling: return what succeeded, mark what failed, and let the frontend decide how to render a degraded state.</p>
<pre><code class="language-csharp">public record DashboardResponse(
    UserProfile Profile,
    IReadOnlyList&lt;Course&gt; Courses,
    IReadOnlyList&lt;Session&gt; UpcomingSessions,
    int NotificationCount,
    IReadOnlyList&lt;string&gt; PartialFailures  // e.g. ["courses", "sessions"]
);
</code></pre>
<p>This is not error handling for its own sake. It is a deliberate contract: the BFF guarantees it will always return a structurally valid response, and the Vue component decides what to render when parts of it are empty.</p>
<hr />
<h2>Response shaping: the frontend owns the shape</h2>
<p>The single most impactful thing a BFF does is decouple the response shape from the domain model. This is also where engineers most frequently make the wrong call.</p>
<p>The wrong call is to return the upstream service's response more or less as-is, perhaps with light field selection. This is understandable — it is the path of least resistance, it keeps the BFF thin, and it avoids the question of what the "right" shape is. But it is not response shaping. It is proxying, and a proxy does not justify the overhead of a dedicated service.</p>
<p>Response shaping means designing the response around the component tree that will consume it. The question is not "what did the upstream service return?" but "what does the Vue component need, and in what shape does it need it?"</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/8bbb449d-0544-4a4f-9050-d37beda782b8.png" alt="" style="display:block;margin:0 auto" />

<h3>Flatten, don't nest</h3>
<p>Domain models are frequently deeply nested because they reflect real-world entity relationships. Frontend components rarely need that nesting — they need flat data they can bind to template properties without traversal logic in the component.</p>
<p>An upstream <code>Course</code> entity might look like this:</p>
<pre><code class="language-json">{
  "id": "c-1",
  "metadata": {
    "title": "Mathematics — Year 9",
    "code": "MATH-9",
    "curriculum": {
      "framework": "NOR-K20",
      "subject": "Mathematics",
      "level": { "grade": 9, "label": "Year 9" }
    }
  },
  "enrollment": {
    "capacity": 30,
    "enrolled": 24,
    "waitlist": 2
  },
  "status": { "code": "ACTIVE", "since": "2024-08-15T00:00:00Z" }
}
</code></pre>
<p>A course card component in Vue needs: a title, a code, the enrollment fraction, and a status label. The BFF shapes this into:</p>
<pre><code class="language-json">{
  "id": "c-1",
  "title": "Mathematics — Year 9",
  "code": "MATH-9",
  "enrollmentLabel": "24 / 30",
  "enrollmentPercent": 80,
  "status": "Active",
  "activeFrom": "2024-08-15"
}
</code></pre>
<p>The component receives exactly what it renders. No traversal logic, no null-guard chains, no formatting in the template. The formatting decisions — how to display the enrollment fraction, how to present the date — are made once, in the BFF, and are consistent across every component that uses this data.</p>
<h3>Computed fields belong in the BFF</h3>
<p>The enrollment label and enrollment percentage in the example above are computed fields — they do not exist in the upstream response and must be derived. They belong in the BFF, not in the component.</p>
<p>The underlying principle: any derivation that is deterministic, presentation-oriented, and would be repeated across multiple components is a BFF concern. This includes percentage calculations, label generation, date formatting, status code translation, and currency formatting with locale awareness.</p>
<p>What does not belong in the BFF: business logic that should live upstream, validation that changes application state, or calculations that depend on runtime user input. The BFF is a rendering layer for data that is already computed — it is not a domain service.</p>
<h3>Naming conventions are a contract decision</h3>
<p>The upstream service uses <code>enrollmentCapacity</code> and <code>enrolledCount</code>. The BFF exposes <code>enrollmentLabel</code> and <code>enrollmentPercent</code>. The Vue component uses <code>enrollmentLabel</code> and <code>enrollmentPercent</code>.</p>
<p>This means your BFF's property names are part of its contract with the frontend. Changing <code>enrollmentLabel</code> to <code>enrollmentText</code> is a breaking change, even if the value is identical. Name properties for their rendering purpose, not their origin, and treat them with the same stability you would expect from any API you consume.</p>
<p>In practice, this argues for establishing naming conventions before writing the first endpoint and enforcing them through code review. Consistency in the contract reduces cognitive load on both sides of it.</p>
<hr />
<h2>Versioning: the problem you are creating</h2>
<p>A BFF contract is an API. APIs have versions, and versions accumulate. This is the most underspecified aspect of BFF design in most articles, because it is uncomfortable to discuss — you are creating a versioning problem in order to solve a coupling problem, and the question is whether the trade is favourable.</p>
<p>There are three approaches, each with a clear use case.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/1a3853da-cc01-4462-ad79-0c99e69d463b.png" alt="" style="display:block;margin:0 auto" />

<h3>URL versioning for major breaking changes</h3>
<pre><code class="language-plaintext">GET /api/v1/dashboard
GET /api/v2/dashboard
</code></pre>
<p>URL versioning is the most visible, most explicit, and most operationally expensive approach. It is appropriate when a response shape change is so significant that the old and new shapes cannot coexist under one contract — for example, when a screen is redesigned and the data model changes completely.</p>
<p>The operational cost is that both versions must be maintained simultaneously during the migration period, and the migration period in practice extends far longer than anticipated. Budget for it.</p>
<h3>Header versioning for incremental evolution</h3>
<pre><code class="language-plaintext">GET /api/dashboard
Accept: application/vnd.bff.dashboard+json; version=2
</code></pre>
<p>Header versioning keeps URLs stable and moves the version negotiation into headers. It is cleaner for incremental evolution — adding fields, changing response structure within a stable conceptual model — and it does not require duplicating route definitions. The cost is that it is less discoverable and requires slightly more discipline in the client to set the header correctly.</p>
<h3>Additive-only evolution: the best versioning strategy</h3>
<p>The most effective versioning strategy is one you do not need. If the BFF contract evolves additively — new fields are added, existing fields are never removed, semantics never change — versioning becomes a maintenance concern rather than a migration project.</p>
<p>This is achievable in practice with two rules:</p>
<p><strong>Never remove a field.</strong> If a field is no longer needed by the frontend, mark it as deprecated in internal documentation and stop populating it (return null), but leave it in the response schema. Removal is a v2 concern.</p>
<p><strong>Never change the semantics of an existing field.</strong> If <code>status</code> currently returns <code>"Active"</code> and you need it to return a structured object, that is a new field — <code>statusDetail</code> — not a change to <code>status</code>. The original field continues as-is.</p>
<p>Additive-only evolution is not infinitely sustainable — response shapes accumulate cruft over time — but it defers versioning costs to the natural cadence of major releases rather than introducing them with every sprint.</p>
<hr />
<h2>The boundary: what belongs in the BFF</h2>
<p>This is the question that determines whether your BFF stays healthy or becomes the new monolith. The answer is a firm principle rather than a checklist: <strong>the BFF transforms and aggregates; it does not originate.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/4ca9da4f-2747-44f7-b4ce-579f1ff79c2d.png" alt="" style="display:block;margin:0 auto" />

<p>What this means in concrete terms:</p>
<h3>BFF owns</h3>
<ul>
<li><p><strong>Response aggregation</strong>: combining data from multiple upstream services into a single response shaped for the Vue component</p>
</li>
<li><p><strong>Field selection and projection</strong>: choosing which upstream fields to include and which to omit</p>
</li>
<li><p><strong>Presentation-layer computation</strong>: formatting, label generation, percentage derivation, date localisation</p>
</li>
<li><p><strong>Authentication enforcement</strong>: validating the session, exchanging tokens, enforcing access before any upstream call is made</p>
</li>
<li><p><strong>Caching of presentation-layer data</strong>: caching aggregated, shaped responses where staleness is acceptable</p>
</li>
<li><p><strong>Error translation</strong>: converting upstream error codes into client-meaningful error shapes</p>
</li>
</ul>
<h3>Upstream services own</h3>
<ul>
<li><p><strong>Business rules</strong>: what constitutes a valid enrollment, whether a course is at capacity, eligibility logic</p>
</li>
<li><p><strong>Domain validation</strong>: ensuring data integrity constraints are enforced at the source of truth</p>
</li>
<li><p><strong>State mutation</strong>: creating, updating, and deleting domain entities</p>
</li>
<li><p><strong>Cross-entity consistency</strong>: ensuring that a session cannot reference a deleted course</p>
</li>
</ul>
<h3>The grey area: where teams disagree</h3>
<p>The genuinely contested cases are usually one of two types:</p>
<p><strong>Computed fields that require domain knowledge.</strong> Is <code>isEnrollmentOpen</code> — a boolean derived from enrollment capacity and a business rule about the waitlist threshold — a presentation concern or a domain concern? The answer: if the rule could change (and business rules do change), it belongs upstream. The BFF should receive a pre-computed <code>enrollmentStatus</code> from the course service, not derive it locally. A BFF that embeds business rules is a BFF that becomes inconsistent with the backend when those rules change.</p>
<p><strong>Input validation on write endpoints.</strong> The BFF handles write operations too — course enrollments, session registrations. Validation that is purely structural (is this field present, is this ID a valid UUID format) is reasonable in the BFF as a fast-fail before the upstream call. Validation that is semantic (is this user eligible to enroll in this course) must happen upstream. Drawing the line here prevents a situation where the BFF and the upstream service have conflicting validation logic.</p>
<p>A practical heuristic: if a product manager could change the rule in a sprint, it belongs upstream. If it is structural and invariant (a user ID is always a UUID), it is acceptable in the BFF.</p>
<hr />
<h2>Designing for the Vue component tree</h2>
<p>The most useful frame for BFF endpoint design is not "what data does this screen need?" but "what does the Vue component tree look like, and what does each component expect from its props?".</p>
<p>A screen is composed of components. Each component has a data contract — its props interface. The BFF response should map cleanly onto that props interface, ideally such that a single destructure in the composable produces the data each component needs without further transformation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/017c6bd1-7520-4a28-afdb-246a1614a825.png" alt="" style="display:block;margin:0 auto" />

<p>In practice this means walking the component tree before designing the endpoint:</p>
<pre><code class="language-plaintext">DashboardView
├── UserProfileCard        → { displayName, role, avatarUrl }
├── CourseListPanel
│   └── CourseCard (×n)   → { id, title, code, enrollmentLabel, status }
├── UpcomingSessionsList
│   └── SessionItem (×n)  → { id, title, startsAt, courseTitle, locationLabel }
└── NotificationBadge      → { count }
</code></pre>
<p>The BFF endpoint for this screen returns an object that mirrors this structure:</p>
<pre><code class="language-json">{
  "user": {
    "displayName": "Ingrid Solberg",
    "role": "Teacher",
    "avatarUrl": "/avatars/i-solberg.jpg"
  },
  "courses": [
    { "id": "c-1", "title": "Mathematics — Year 9", "code": "MATH-9",
      "enrollmentLabel": "24 / 30", "status": "Active" }
  ],
  "upcomingSessions": [
    { "id": "s-1", "title": "Integration review", "startsAt": "2025-04-08T09:00:00",
      "courseTitle": "Mathematics — Year 9", "locationLabel": "Room 204" }
  ],
  "notifications": { "count": 3 }
}
</code></pre>
<p>The Vue composable for this screen receives this response and distributes it to components without further transformation:</p>
<pre><code class="language-typescript">const { data } = useDashboard()

// Each ref maps directly to a component's props
const user = computed(() =&gt; data.value?.user)
const courses = computed(() =&gt; data.value?.courses ?? [])
const upcomingSessions = computed(() =&gt; data.value?.upcomingSessions ?? [])
const notificationCount = computed(() =&gt; data.value?.notifications.count ?? 0)
</code></pre>
<p>No adapter logic. No field mapping. The BFF contract and the component props interface are aligned by design.</p>
<hr />
<h2>A note on OpenAPI and type safety</h2>
<p>A BFF contract without a schema is a verbal agreement. It will drift. The response shape the BFF returns today will not match what the Vue components expect in three months, and you will discover the mismatch at runtime.</p>
<p>The mitigation is simple and should be non-negotiable: define the BFF contract with OpenAPI, generate TypeScript types from it, and import those types into the Vue application. Changes to the BFF response shape become compile-time errors in the frontend before they reach the browser.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/398a0648-d484-450f-81a6-10342b5dc97c.png" alt="" style="display:block;margin:0 auto" />

<p>In .NET Core, Swashbuckle generates an OpenAPI spec from controller or Minimal API route definitions automatically. In Vue 3, <code>openapi-typescript</code> or <code>@hey-api/openapi-ts</code> generates typed interfaces from that spec. The generation step belongs in the build pipeline, not as a manual step.</p>
<p>This is not optional complexity. A BFF that lacks type-safe contracts between its two surfaces — the upstream services and the Vue client — is a BFF that will break silently in production.</p>
<hr />
<h2>What comes next</h2>
<p>This article has covered the design principles that govern a well-structured BFF contract. The next article steps back before the implementation begins to address the comparative question that should be answered before writing any code: how does BFF compare to API Gateway and GraphQL as architectural options, where does each pattern win, and where do they coexist?</p>
<hr />
<h2>☰ Series navigation</h2>
<aside>
  <a href="/introduction-to-the-frontend-s-contract-building-backends-for-frontends-with-vue-js-net-core-azure">Introduction</a>
  <div style="margin-top:18px;margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#1A6B4A,#34C88A);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part I — Foundations</span>
    </div>
    <ul>
      <li><a href="#">What Is BFF — and When Is It Actually Worth It?</a></li>
      <li>→ <a href="#">Designing the BFF Contract: Request Aggregation &amp; Client-Specific Shaping</a></li>
      <li><a href="#">BFF vs API Gateway vs GraphQL: Picking the Right Abstraction</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#2D52A0,#6B9FEF);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part II — Implementation</span>
    </div>
    <ul>
      <li><a href="#">Building the BFF in .NET Core: Minimal APIs, Routing &amp; Aggregation</a></li>
      <li><a href="#">The Vue 3 API Layer: Composables, Error Boundaries &amp; Type Safety</a></li>
      <li><a href="#">Auth at the Boundary: Integrating Feide Identity via the BFF</a></li>
      <li><a href="#">Shipping to Azure: Docker Images, Artifact Publishing &amp; Azure Container Instances</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#7C3E8F,#C084E8);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part III — Production &amp; Operations</span>
    </div>
    <ul>
      <li><a href="#">Testing the BFF: Unit, Integration &amp; Contract Tests</a></li>
      <li><a href="#">Observability: Structured Logging, Distributed Tracing &amp; Azure Application Insights</a></li>
    </ul>
  </div>
  <div>
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#B45309,#F5A623);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Supplementary</span>
    </div>
    <ul>
      <li><a href="#">Caching in the BFF: In-Memory, Redis &amp; Response Caching</a></li>
      <li><a href="#">Brownfield Migration: The Strangler Fig Approach to BFF Adoption</a></li>
      <li><a href="#">Resilience Patterns: Circuit Breakers, Retries &amp; Timeouts with Polly</a></li>
    </ul>
  </div>
</aside>]]></content:encoded></item><item><title><![CDATA[What Is BFF — and When Is It Actually Worth It?]]></title><description><![CDATA[Your frontend has outgrown the API it was given.

At some point, most frontend teams hit the same wall. The backend exposes what it knows — resources, entities, service boundaries — and the frontend i]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/what-is-bff-and-when-is-it-actually-worth-it</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/what-is-bff-and-when-is-it-actually-worth-it</guid><category><![CDATA[Backend for frontend]]></category><category><![CDATA[BFF Pattern]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[API Design]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[FrontendArchitecture]]></category><category><![CDATA[Microservices]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 22 Mar 2026 04:22:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/71a91b5f-2c7d-46c1-9d6f-65c1665521fd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Your frontend has outgrown the API it was given.</p>
</blockquote>
<p>At some point, most frontend teams hit the same wall. The backend exposes what it knows — resources, entities, service boundaries — and the frontend is left stitching four API calls into a single screen, massaging data shapes the UI never asked for, and writing adapter logic that has no good place to live. The Backend for Frontend pattern is the answer to that wall. But it comes with a cost: an additional service to build, deploy, and own. This series makes the case for that trade-off — and equally, for the cases where it is not worth making. The examples and architecture decisions throughout are drawn from a production implementation built for a Norwegian enterprise in the education sector. Where the original system cannot be described in full, concepts have been generalised to meet NDA obligations — but the engineering trade-offs, the failure modes, and the decisions that shaped the final design are real.</p>
<hr />
<h2>The problem, stated plainly</h2>
<p>Before defining what a BFF is, it is worth being precise about what problem actually warrants one.</p>
<p>Imagine a dashboard screen in an education platform. It needs to render: the current user's profile and role, their organisation's enrolled courses, upcoming sessions for the current week, and unread notifications. In a system where services are organised around domain entities, that screen requires calls to at least four separate endpoints — likely across two or three different services. The responses come back in shapes optimised for storage and domain logic, not for what this particular screen needs.</p>
<p>The frontend handles it. It fires the requests, waits for them to resolve, merges the data, filters out the fields it does not need, transforms date formats, normalises inconsistent ID conventions between services, and then renders. This works. It is also a slow, fragile, and increasingly expensive pattern at scale.</p>
<p>Three specific failure modes appear consistently once systems grow:</p>
<p><strong>Overfetching and underfetching.</strong> REST endpoints designed around domain entities return either too much or too little for any given screen. A <code>GET /users/{id}</code> response that includes billing history, audit logs, and security settings satisfies the account settings page — but it is wasteful when all the dashboard needs is a display name and an avatar URL. Conversely, a screen requiring data from three different resource types must make three round trips, each adding latency, each adding a potential failure point.</p>
<p><strong>Adapter logic with no home.</strong> The gap between what upstream services return and what the frontend needs gets filled somewhere. In the absence of a dedicated layer, it lands in the frontend itself — in Vuex stores, in composables, in utility functions scattered across the codebase. This logic is hard to test in isolation, invisible to the backend teams who changed the API shape that broke it, and re-implemented separately for each client surface (web app, mobile app, third-party integration).</p>
<p><strong>Security and session complexity pushed to the client.</strong> When the frontend talks directly to multiple APIs, it must manage tokens for each of them, handle token refresh across parallel requests, and decide what to expose in the browser. This is not where security boundaries should be drawn. The browser is not a trusted environment, and treating it as one creates problems that are difficult to retrofit away later.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/b624a4c0-e27f-4c99-9675-8ec24d39f340.png" alt="" style="display:block;margin:0 auto" />

<p>None of these problems are fatal on their own, early in a product's life. The question is what you reach for when they compound — and BFF is one answer, not the only one.</p>
<hr />
<h2>What BFF actually is</h2>
<p>The Backend for Frontend pattern, first articulated by Sam Newman in the context of microservices architecture, is straightforward in principle: create a dedicated backend service for each distinct frontend client, owned by the frontend team, whose sole responsibility is serving that client's specific needs.</p>
<p>The key words are <em>dedicated</em> and <em>owned by the frontend team</em>. A BFF is not a general-purpose API gateway. It is not a shared middleware layer. It is a service that knows exactly one consumer — your frontend — and is optimised entirely for that consumer's needs. It aggregates calls to upstream services, shapes responses into exactly what the UI requires, handles authentication at the boundary, and shields the frontend from the complexity and instability of the services behind it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/596e90a5-d6a5-47e1-b75c-530c7e420109.png" alt="" style="display:block;margin:0 auto" />

<p>The Vue application has one API contract to reason about: the BFF. It does not know or care how many upstream services exist, how they are versioned, or what their response shapes look like. That complexity lives in the BFF, where it can be tested, versioned, and changed independently.</p>
<p>The BFF can do several things the frontend cannot do cleanly on its own: it can fire multiple upstream requests in parallel and merge the results before responding; it can cache aggressively for data that does not change per-request; it can enforce authentication and authorisation before a single upstream call is made; and it can translate between authentication contexts — for example, exchanging a session cookie for a service-to-service token without ever exposing a bearer token to the browser.</p>
<hr />
<h2>What BFF actually costs</h2>
<p>This is where most introductory articles skip ahead too quickly. The BFF pattern is genuinely useful — but the version of it that works in production looks different from the version described in architecture blog posts, and the gap is filled with operational cost.</p>
<p><strong>You are taking on a new service.</strong> This sounds obvious but its implications are underestimated. A new service means a new deployment pipeline, a new container to monitor, a new set of logs to aggregate, a new failure mode to handle, a new component in your runbook. If your team does not already own infrastructure or has not previously maintained a backend service, the operational learning curve is real. The BFF will go down. It will have bugs. It will need updating when upstream services change their contracts. These are not hypothetical costs — they are the routine maintenance costs of any production service, and they do not disappear because the service is thin.</p>
<p><strong>The BFF becomes a coupling point.</strong> When your Vue application and your upstream services are decoupled by a BFF, the BFF is not free of coupling — it absorbs it. Every upstream API change that affects the frontend now requires a BFF change too. In a fast-moving system, this can mean the BFF becomes a bottleneck: a place where changes must land before they can reach the frontend. The team that owns the BFF becomes the team that must be unblocked first.</p>
<p><strong>Latency is not free.</strong> A BFF adds one network hop between the browser and its data. For most production deployments — where the BFF and its upstream services are colocated in the same cloud region — this hop is in the single-digit milliseconds. But it exists, and for systems already operating close to latency budgets, it matters. The mitigation is co-deployment discipline and caching, both of which require deliberate effort.</p>
<p><strong>The BFF can become a dumping ground.</strong> This is the failure mode no one talks about in architecture talks. A BFF that starts as a clean aggregation layer accumulates business logic over time. A validation rule here, a conditional transform there, a calculation that "just needs to live somewhere." Left unchecked, a BFF becomes a monolith with the word "frontend" in its name. The discipline to keep it thin — a translator and aggregator, not a domain engine — is cultural as much as technical, and it requires active enforcement.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/b477b917-d883-4589-81b5-4b4e4874b094.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>When BFF is worth it</h2>
<p>With that context established, the cases where BFF earns its overhead are clearer.</p>
<p><strong>Multiple upstream services, single UI surface.</strong> If your frontend needs to aggregate data from three or more independent services for routine screens, the aggregation cost is already being paid somewhere. Paying it in the BFF — where it can be tested, cached, and monitored — is better than paying it in the client or across a distributed chain of sequential API calls.</p>
<p><strong>Multiple client surfaces with diverging needs.</strong> A web application, a mobile app, and a third-party integration consume fundamentally different API shapes. A response payload appropriate for a desktop dashboard is wasteful over a mobile connection. A BFF per client surface means each client gets exactly what it needs, without the upstream services needing to know or care about client-specific requirements. This is the original use case Sam Newman described, and it remains the strongest one.</p>
<p><strong>Security boundary clarity.</strong> If your system involves tokens that must never reach the browser, or authentication flows that require server-side session management — as is the case with Feide, the Norwegian government identity provider used in this series — a BFF gives you a clean place to draw the security perimeter. The BFF holds the session, manages token exchange, and the browser only ever receives a cookie. This is the Token Handler pattern, and it is substantially harder to implement correctly without a dedicated server-side layer.</p>
<p><strong>Unstable upstream contracts.</strong> In a microservices environment where teams are moving fast and breaking things at the API layer, a BFF acts as a translation buffer. When an upstream service changes its response shape, you update the BFF. The Vue application is insulated. Without the BFF, that upstream change propagates directly into frontend code — often discovered at runtime rather than compile time.</p>
<p><strong>Team ownership alignment.</strong> Perhaps the least technical but most practically important factor: if the frontend team has the capacity to own a backend service, a BFF gives them the autonomy to move at their own pace without being blocked on backend teams for API shape changes. This is an organisational argument as much as an architectural one, and it should be evaluated as such.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/a17939cf-aaaa-4862-9249-846f4e6967b7.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>When BFF is not worth it</h2>
<p>This section is the one most articles omit. The BFF pattern has a real overhead floor that you pay regardless of system complexity. Below a certain threshold, that floor is higher than the problems it solves.</p>
<p><strong>Small teams moving fast on a single surface.</strong> If you have one frontend, one backend, and a team of three engineers, a BFF introduces a coordination overhead between your own people that does not exist if the frontend talks directly to the API. The aggregation and shaping problems are real, but they are solvable with thoughtful API design, GraphQL, or simply accepting a thin amount of adapter logic in the frontend until the system is large enough to justify more structure.</p>
<p><strong>A well-designed monolithic API.</strong> If your backend already returns response shapes close to what the frontend needs — because the backend team works closely with the frontend team, or because the API was designed frontend-first — a BFF adds a layer without adding meaningful value. The problem a BFF solves is the impedance mismatch between backend domain models and frontend presentation needs. If that mismatch is small, the solution is disproportionate.</p>
<p><strong>Early-stage products with unstable requirements.</strong> A BFF contract between the frontend and its upstream services is another API surface to maintain. In the early life of a product, when screen designs change weekly and domain models are still being discovered, the BFF becomes a change multiplier: every significant UI change requires a frontend change, a BFF change, and potentially an upstream change. The stability that makes a BFF valuable is the same stability that is absent in early-stage development.</p>
<p><strong>Teams without infrastructure ownership.</strong> If your team has never maintained a deployed service — never dealt with container health checks, never written a deployment pipeline, never handled a 3am incident for something they own — adopting a BFF in production is learning two hard things simultaneously: the architecture and the operations. This is not a reason to avoid BFF permanently, but it is a reason to be honest about timing and capacity.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/cd5d0812-03f7-4cc7-882e-6380318f4f3e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The decision framework</h2>
<p>Rather than a checklist, use three questions. If the answer to fewer than two is "yes," the BFF overhead is likely not justified at this stage.</p>
<p><strong>Does your frontend aggregate data from three or more independent services for routine operations?</strong> If most screens require only one or two API calls that already return the right shape, the aggregation value proposition is weak.</p>
<p><strong>Do you have a meaningful security or session management requirement that cannot be cleanly handled in the client?</strong> If your authentication flow is stateless, token-based, and entirely client-managed, the security argument for BFF does not apply. If you are dealing with server-side sessions, token exchange, or an identity provider like Feide that requires server-side handling, it does.</p>
<p><strong>Does your team have the capacity to own and operate a backend service independently?</strong> This means a deployment pipeline, monitoring, alerting, runbooks, and the willingness to be on-call for it. BFF without operational ownership is technical debt in a server rack.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/d9f56524-4617-4886-9d32-4aea50917708.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What this series covers from here</h2>
<p>The rest of this series assumes the answer is "yes, BFF is the right call." If it is not — if you read this article and concluded that your system is not there yet — the single most useful thing you can do is bookmark the decision framework above and revisit it in six months. Architecture decisions should trail system complexity, not lead it.</p>
<p>For those continuing: the next article addresses how to design the BFF contract itself — what belongs inside it, what must stay in upstream services, and how to version the API surface you are creating without recreating the problems you were trying to solve.</p>
<p>The implementation articles that follow use .NET Core Minimal APIs for the BFF service, Vue 3 composables for the client-side API layer, Feide for authentication, and Azure Container Instances for deployment. Each article is self-contained, but the architecture decisions made in the early articles carry forward — so reading in order is the path of least resistance.</p>
<hr />
<h2>☰ Series navigation</h2>
<aside>
  <a href="/introduction-to-the-frontend-s-contract-building-backends-for-frontends-with-vue-js-net-core-azure">Introduction</a>
  <div style="margin-top:18px;margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#1A6B4A,#34C88A);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part I — Foundations</span>
    </div>
    <ul>
      <li>→ <a href="#">What Is BFF — and When Is It Actually Worth It?</a></li>
      <li><a href="#">Designing the BFF Contract: Request Aggregation &amp; Client-Specific Shaping</a></li>
      <li><a href="#">BFF vs API Gateway vs GraphQL: Picking the Right Abstraction</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#2D52A0,#6B9FEF);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part II — Implementation</span>
    </div>
    <ul>
      <li><a href="#">Building the BFF in .NET Core: Minimal APIs, Routing &amp; Aggregation</a></li>
      <li><a href="#">The Vue 3 API Layer: Composables, Error Boundaries &amp; Type Safety</a></li>
      <li><a href="#">Auth at the Boundary: Integrating Feide Identity via the BFF</a></li>
      <li><a href="#">Shipping to Azure: Docker Images, Artifact Publishing &amp; Azure Container Instances</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#7C3E8F,#C084E8);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part III — Production &amp; Operations</span>
    </div>
    <ul>
      <li><a href="#">Testing the BFF: Unit, Integration &amp; Contract Tests</a></li>
      <li><a href="#">Observability: Structured Logging, Distributed Tracing &amp; Azure Application Insights</a></li>
    </ul>
  </div>
  <div>
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#B45309,#F5A623);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Supplementary</span>
    </div>
    <ul>
      <li><a href="#">Caching in the BFF: In-Memory, Redis &amp; Response Caching</a></li>
      <li><a href="#">Brownfield Migration: The Strangler Fig Approach to BFF Adoption</a></li>
      <li><a href="#">Resilience Patterns: Circuit Breakers, Retries &amp; Timeouts with Polly</a></li>
    </ul>
  </div>
</aside>]]></content:encoded></item><item><title><![CDATA[Introduction to The Frontend's Contract: Building Backends for Frontends
with Vue.js, .NET Core & Azure]]></title><description><![CDATA[At some point, most frontend teams hit the same wall. The backend exposes what it knows — resources, entities, service boundaries — and the frontend is left stitching four API calls into a single scre]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/introduction-to-the-frontend-s-contract-building-backends-for-frontends-with-vue-js-net-core-azure</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/introduction-to-the-frontend-s-contract-building-backends-for-frontends-with-vue-js-net-core-azure</guid><category><![CDATA[Backend for frontend]]></category><category><![CDATA[BFF Pattern]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[API Design]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[.net core]]></category><category><![CDATA[Azure]]></category><category><![CDATA[FrontendArchitecture]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sat, 21 Mar 2026 04:49:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6931380619a4497f08a1d0fd/e57052d8-245e-41b6-8a07-cc50d9c8b05a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>At some point, most frontend teams hit the same wall. The backend exposes what it knows — resources, entities, service boundaries — and the frontend is left stitching four API calls into a single screen, massaging data shapes the UI never asked for, and writing adapter logic that has no good place to live. The Backend for Frontend pattern is the answer to that wall. But it comes with a cost: an additional service to build, deploy, and own. This series makes the case for that trade-off — and equally, for the cases where it is not worth making. The examples and architecture decisions throughout are drawn from a production implementation built for a Norwegian enterprise in the education sector. Where the original system cannot be described in full, concepts have been generalised to meet NDA obligations — but the engineering trade-offs, the failure modes, and the decisions that shaped the final design are real.</p>
<blockquote>
<p><em>"Every frontend eventually outgrows the API it was given. The BFF pattern is not about adding a layer — it's about taking ownership of the interface between your product and its backend, on your terms. This series is for engineers who want to understand the trade-offs before they commit to the architecture."</em></p>
</blockquote>
<h2>Series Guideline</h2>
<h3><strong>Part I: Foundations (Concepts and Architecture)</strong></h3>
<ul>
<li><p><strong>Article 1: What Is BFF — and When Is It Actually Worth It?</strong><br />The problem it solves, the cost it introduces, and the honest answer on when not to use it.</p>
</li>
<li><p><strong>Article 2: Designing the BFF Contract: Request Aggregation &amp; Client-Specific Shaping</strong><br />API design principles, response shaping, versioning strategy, and what belongs in the BFF vs upstream services.</p>
</li>
<li><p><strong>Article 3: BFF vs API Gateway vs GraphQL: Picking the Right Abstraction</strong><br />Comparative analysis with real trade-offs. Where each pattern wins, where it falls over, and how they can coexist.</p>
</li>
</ul>
<h3><strong>Part II: Implementation (Code)</strong></h3>
<ul>
<li><p><strong>Article 4: Building the BFF in .NET Core: Minimal APIs, Routing &amp; Aggregation</strong><br />Standing up the BFF service, aggregating upstream calls, shaping responses, and handling errors with real code.</p>
</li>
<li><p><strong>Article 5: The Vue 3 API Layer: Composables, Error Boundaries &amp; Type Safety</strong><br />Building a clean, typed client-BFF contract in Vue 3. useApi composables, error handling strategies, and OpenAPI codegen.</p>
</li>
<li><p><strong>Article 6: Auth at the Boundary: Integrating Feide Identity via the BFF</strong><br />Connecting the BFF to Feide — Norway's government-issued identity provider for educational organisations. OAuth 2.0 + OIDC flow, the Token Handler pattern, and why cookie-based sessions beat tokens in the browser.</p>
</li>
<li><p><strong>Article 7: Shipping to Azure: Docker Images, Artifact Publishing &amp; Azure Container Instances</strong><br />Full IaaS deployment pipeline — building and tagging Docker images, publishing artifacts, and running the BFF on Azure Container Instances. Includes Azure Front Door routing and when API Management adds value vs noise.</p>
</li>
</ul>
<h3><strong>Part III: Production &amp; Operations (Ops)</strong></h3>
<ul>
<li><p><strong>Article 8: Testing the BFF: Unit, Integration &amp; Contract Tests</strong>A layered testing strategy for the BFF. WebApplicationFactory for integration tests, Pact for consumer-driven contract testing with Vue.</p>
</li>
<li><p><strong>Article 9: Observability: Structured Logging, Distributed Tracing &amp; Azure Application Insights</strong><br />End-to-end traceability across Vue → BFF → upstream services using Azure Application Insights. Correlation IDs, structured logs with Serilog, custom telemetry, and Application Insights dashboards and alerts.</p>
</li>
</ul>
<h3><strong>Supplementary articles</strong></h3>
<ul>
<li><p><strong>Resilience Patterns: Circuit Breakers, Retries &amp; Timeouts with Polly</strong><br />Making the BFF fault-tolerant using Polly. Handling partial upstream failures gracefully in aggregated responses.</p>
</li>
<li><p><strong>Caching in the BFF: In-Memory, Redis &amp; Response Caching</strong><br />Where caching belongs in a BFF architecture, how to avoid stale-data bugs, and cache invalidation patterns.</p>
</li>
<li><p><strong>Brownfield Migration: The Strangler Fig Approach to BFF Adoption</strong><br />Incrementally introducing a BFF in front of an existing monolith or REST API without a big-bang rewrite.</p>
</li>
</ul>
<hr />
<h2>☰ Series navigation</h2>
<aside>
  → <a href="/introduction-to-the-frontend-s-contract-building-backends-for-frontends-with-vue-js-net-core-azure">Introduction</a>
  <div style="margin-top:18px;margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#1A6B4A,#34C88A);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part I — Foundations</span>
    </div>
    <ul>
      <li><a href="#">What Is BFF — and When Is It Actually Worth It?</a></li>
      <li><a href="#">Designing the BFF Contract: Request Aggregation &amp; Client-Specific Shaping</a></li>
      <li><a href="#">BFF vs API Gateway vs GraphQL: Picking the Right Abstraction</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#2D52A0,#6B9FEF);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part II — Implementation</span>
    </div>
    <ul>
      <li><a href="#">Building the BFF in .NET Core: Minimal APIs, Routing &amp; Aggregation</a></li>
      <li><a href="#">The Vue 3 API Layer: Composables, Error Boundaries &amp; Type Safety</a></li>
      <li><a href="#">Auth at the Boundary: Integrating Feide Identity via the BFF</a></li>
      <li><a href="#">Shipping to Azure: Docker Images, Artifact Publishing &amp; Azure Container Instances</a></li>
    </ul>
  </div>
  <div style="margin-bottom:18px">
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#7C3E8F,#C084E8);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Part III — Production &amp; Operations</span>
    </div>
    <ul>
      <li><a href="#">Testing the BFF: Unit, Integration &amp; Contract Tests</a></li>
      <li><a href="#">Observability: Structured Logging, Distributed Tracing &amp; Azure Application Insights</a></li>
    </ul>
  </div>
  <div>
    <div style="display:flex;align-items:center;gap:7px;margin-bottom:6px">
      <span style="width:5px;height:5px;border-radius:50%;background:light-dark(#B45309,#F5A623);flex-shrink:0;display:inline-block"></span>
      <span style="font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:light-dark(#9E9B94,#5E5C56)">Supplementary</span>
    </div>
    <ul>
      <li><a href="#">Caching in the BFF: In-Memory, Redis &amp; Response Caching</a></li>
      <li><a href="#">Brownfield Migration: The Strangler Fig Approach to BFF Adoption</a></li>
      <li><a href="#">Resilience Patterns: Circuit Breakers, Retries &amp; Timeouts with Polly</a></li>
    </ul>
  </div>
</aside>]]></content:encoded></item><item><title><![CDATA[How Browser UX Shapes Security More Than Cryptography]]></title><description><![CDATA[Cryptography is precise.
Browsers are not.
If you’ve implemented WebAuthn in a real PWA, you already know this:The spec is clean. The user experience is not.
The uncomfortable truth is this:

Most authentication systems fail because of UX, not becaus...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography</guid><category><![CDATA[#webauthn]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[Authentication UX]]></category><category><![CDATA[browser security]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[security architecture]]></category><category><![CDATA[user experience]]></category><category><![CDATA[identity design]]></category><category><![CDATA[Application Security]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Thu, 19 Feb 2026 07:43:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771475868373/db5ddead-4354-48f6-922c-8c315394778a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cryptography is precise.</p>
<p>Browsers are not.</p>
<p>If you’ve implemented WebAuthn in a real PWA, you already know this:<br />The spec is clean. The user experience is not.</p>
<p>The uncomfortable truth is this:</p>
<blockquote>
<p>Most authentication systems fail because of UX, not because of broken cryptography.</p>
</blockquote>
<p>WebAuthn gives us origin binding, challenge–response, and public-key authentication. That’s beautiful. But what users actually interact with is:</p>
<ul>
<li><p>A browser modal.</p>
</li>
<li><p>An OS biometric sheet.</p>
</li>
<li><p>A permission dialog.</p>
</li>
<li><p>A vague error message.</p>
</li>
<li><p>A “NotAllowedError”.</p>
</li>
</ul>
<p>And those surfaces shape behavior more than any algorithm ever will.</p>
<p>Let’s examine how browser and OS UX decisions constrain authentication design — and why UX discipline is often more important than cryptographic strength.</p>
<hr />
<h1 id="heading-1-browser-and-os-ux-constrain-your-architecture">1. Browser and OS UX Constrain Your Architecture</h1>
<p>When you call:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">await</span> navigator.credentials.get({
  <span class="hljs-attr">publicKey</span>: options
});
</code></pre>
<p>You are not in control anymore.</p>
<p>The browser:</p>
<ul>
<li><p>Decides how the prompt looks.</p>
</li>
<li><p>Decides when it appears.</p>
</li>
<li><p>Decides how cancellation behaves.</p>
</li>
<li><p>Decides what error is returned.</p>
</li>
<li><p>Delegates to the OS for biometric UI.</p>
</li>
</ul>
<p>Your PWA is a spectator.</p>
<h2 id="heading-example-timing-assumptions">Example: Timing Assumptions</h2>
<p>You might assume:</p>
<ul>
<li><p>The WebAuthn prompt appears immediately.</p>
</li>
<li><p>The user understands what is happening.</p>
</li>
<li><p>Cancellation is intentional.</p>
</li>
</ul>
<p>In reality:</p>
<ul>
<li><p>On Chrome desktop, the modal may appear inline.</p>
</li>
<li><p>On Safari (macOS), Touch ID sheet drops from the top.</p>
</li>
<li><p>On iOS Safari, Face ID overlay obscures the entire screen.</p>
</li>
<li><p>On Android Chrome, the prompt may feel like a system dialog unrelated to your app.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771476177485/afcbe9b5-d2bd-42b5-8a1c-3aecb2bf67e6.png" alt class="image--center mx-auto" /></p>
<p>Your architecture must not depend on:</p>
<ul>
<li><p>Specific timing.</p>
</li>
<li><p>Specific modal appearance.</p>
</li>
<li><p>Immediate resolution.</p>
</li>
</ul>
<p>This is not a cosmetic issue. It affects retry logic and fallback strategy.</p>
<hr />
<h1 id="heading-2-the-same-webauthn-flow-feels-different-everywhere">2. The Same WebAuthn Flow Feels Different Everywhere</h1>
<p>The WebAuthn API is standardized.</p>
<p>The UX is not.</p>
<h3 id="heading-chrome-desktop">Chrome (Desktop)</h3>
<ul>
<li><p>Inline modal.</p>
</li>
<li><p>Clear “Use another device” option.</p>
</li>
<li><p>Relatively consistent error messaging.</p>
</li>
</ul>
<h3 id="heading-safari-macos">Safari (macOS)</h3>
<ul>
<li><p>OS-native Touch ID sheet.</p>
</li>
<li><p>Less explicit fallback controls.</p>
</li>
<li><p>Errors often appear as generic cancellation.</p>
</li>
</ul>
<h3 id="heading-ios-safari">iOS Safari</h3>
<ul>
<li><p>Full-screen Face ID overlay.</p>
</li>
<li><p>Sometimes minimal explanation.</p>
</li>
<li><p>Cancellation feels like app failure.</p>
</li>
</ul>
<h3 id="heading-android-chrome">Android Chrome</h3>
<ul>
<li><p>OS biometric dialog.</p>
</li>
<li><p>Slightly different copy.</p>
</li>
<li><p>Device PIN fallback flows vary by manufacturer.</p>
</li>
</ul>
<p>Your code may be identical:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">try</span> {
  <span class="hljs-keyword">const</span> assertion = <span class="hljs-keyword">await</span> navigator.credentials.get({ <span class="hljs-attr">publicKey</span>: options });
} <span class="hljs-keyword">catch</span> (err) {
  handleError(err);
}
</code></pre>
<p>But <code>err.name</code> and user interpretation differ.</p>
<h2 id="heading-real-example-cancellation-handling">Real Example: Cancellation Handling</h2>
<p>Common error:</p>
<pre><code class="lang-javascript">DOMException: NotAllowedError
</code></pre>
<p>This can mean:</p>
<ul>
<li><p>User cancelled.</p>
</li>
<li><p>Timeout expired.</p>
</li>
<li><p>Platform authenticator unavailable.</p>
</li>
<li><p>Permission denied.</p>
</li>
</ul>
<p>From your frontend perspective:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">catch</span> (err) {
  <span class="hljs-keyword">if</span> (err.name === <span class="hljs-string">"NotAllowedError"</span>) {
    showRetry();
  }
}
</code></pre>
<p>But retry logic must consider:</p>
<ul>
<li><p>Did the user intentionally cancel?</p>
</li>
<li><p>Did the biometric sensor fail?</p>
</li>
<li><p>Is WebAuthn unsupported?</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771476481925/2053d7e3-6743-4115-a620-d20e8ea41447.png" alt class="image--center mx-auto" /></p>
<p>If you misinterpret cancellation as attack — you create lockouts.</p>
<p>If you misinterpret failure as benign — you create confusion.</p>
<p>UX interpretation is part of your threat model.</p>
<hr />
<h1 id="heading-3-permission-dialogs-shape-security-outcomes">3. Permission Dialogs Shape Security Outcomes</h1>
<p>Consider initial WebAuthn registration:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">await</span> navigator.credentials.create({
  <span class="hljs-attr">publicKey</span>: options
});
</code></pre>
<p>Browser may ask:</p>
<ul>
<li><p>“Allow this site to use your security key?”</p>
</li>
<li><p>“Allow Touch ID for this site?”</p>
</li>
</ul>
<p>If your UI does not clearly prepare the user:</p>
<ul>
<li><p>The permission dialog feels suspicious.</p>
</li>
<li><p>The user cancels reflexively.</p>
</li>
<li><p>They choose fallback instead.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771476787979/7ff7727d-e82c-4305-924a-90d921722adf.png" alt class="image--center mx-auto" /></p>
<p>Repeated friction trains users to:</p>
<ul>
<li><p>Prefer weaker flows.</p>
</li>
<li><p>Avoid passwordless enrollment.</p>
</li>
</ul>
<p>Strong crypto loses to confusing UX.</p>
<hr />
<h1 id="heading-4-retry-flows-influence-security-behavior">4. Retry Flows Influence Security Behavior</h1>
<p>Imagine this frontend flow:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">startWebAuthn</span>(<span class="hljs-params">options</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> assertion = <span class="hljs-keyword">await</span> navigator.credentials.get({ <span class="hljs-attr">publicKey</span>: options });
    <span class="hljs-keyword">await</span> verify(assertion);
  } <span class="hljs-keyword">catch</span> (err) {
    showRetry();
  }
}
</code></pre>
<p>If “Retry” automatically triggers WebAuthn again without context, users may:</p>
<ul>
<li><p>Rapidly cancel.</p>
</li>
<li><p>Assume something is broken.</p>
</li>
<li><p>Switch to fallback.</p>
</li>
</ul>
<p>Instead, better UX:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">if</span> (err.name === <span class="hljs-string">"NotAllowedError"</span>) {
  showMessage(<span class="hljs-string">"Authentication was cancelled. Try again or use Feide login."</span>);
}
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771485578690/f7c3647b-9d46-48fe-a21c-bf24f6cb2ef4.png" alt class="image--center mx-auto" /></p>
<p>Explicit fallback messaging prevents:</p>
<ul>
<li><p>Panic.</p>
</li>
<li><p>Repeated failure loops.</p>
</li>
<li><p>Insecure workaround requests (“Can you disable this for me?”).</p>
</li>
</ul>
<p>Retries are not neutral. They shape behavior.</p>
<hr />
<h1 id="heading-5-browser-ux-affects-security-perception">5. Browser UX Affects Security Perception</h1>
<p>Security systems rely on trust perception.</p>
<p>If the browser modal:</p>
<ul>
<li><p>Looks native and familiar → user trusts it.</p>
</li>
<li><p>Looks alien or unexpected → user suspects phishing.</p>
</li>
</ul>
<p>That’s why WebAuthn is powerful:</p>
<p>Origin binding ensures the browser only shows credentials for the correct site.</p>
<p>But the user doesn’t see origin binding.<br />They see a modal.</p>
<p>Your UI must:</p>
<ul>
<li><p>Clearly explain what is about to happen.</p>
</li>
<li><p>Avoid surprising transitions.</p>
</li>
<li><p>Avoid triggering WebAuthn automatically without context.</p>
</li>
</ul>
<p>Example:</p>
<p>Instead of immediately calling WebAuthn:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">button</span> @<span class="hljs-attr">click</span>=<span class="hljs-string">"authenticate"</span>&gt;</span>
  Sign in with device
<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
</code></pre>
<p>Make the user initiate the action.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771485857210/70539f7f-1f24-42ce-ad68-bb80d93bb091.png" alt class="image--center mx-auto" /></p>
<p>User agency increases trust.</p>
<hr />
<h1 id="heading-6-why-good-ux-prevents-insecure-workarounds">6. Why Good UX Prevents Insecure Workarounds</h1>
<p>Users do not attack your system.</p>
<p>They bypass it.</p>
<p>If passwordless is confusing, they will:</p>
<ul>
<li><p>Ask support to disable it.</p>
</li>
<li><p>Request email-based fallback.</p>
</li>
<li><p>Demand “simpler login”.</p>
</li>
</ul>
<p>If fallback is weak, security erodes.</p>
<p>Good UX reduces these pressures.</p>
<p>Example: Clear device management UI.</p>
<p>Instead of hiding credentials:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> devices = <span class="hljs-keyword">await</span> _db.WebAuthnCredentials
    .Where(c =&gt; c.UserId == user.Id)
    .ToListAsync();
</code></pre>
<p>Expose:</p>
<ul>
<li><p>Device name</p>
</li>
<li><p>Registration date</p>
</li>
<li><p>Revoke button</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771486265089/4c8d3fe6-d3ec-45e6-b28b-695e1af58152.png" alt class="image--center mx-auto" /></p>
<p>Transparency builds confidence.</p>
<hr />
<h1 id="heading-7-browser-constraints-affect-architecture">7. Browser Constraints Affect Architecture</h1>
<p>You cannot:</p>
<ul>
<li><p>Customize biometric prompt text.</p>
</li>
<li><p>Force specific fallback options.</p>
</li>
<li><p>Guarantee consistent timing.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771486464371/489f72a7-1644-4569-a32e-9332430e7a3e.png" alt class="image--center mx-auto" /></p>
<p>Therefore, architecture must:</p>
<ul>
<li><p>Avoid assuming prompt content.</p>
</li>
<li><p>Avoid assuming immediate response.</p>
</li>
<li><p>Support retry and fallback cleanly.</p>
</li>
<li><p>Log error patterns per browser.</p>
</li>
</ul>
<p>Operationally, track:</p>
<ul>
<li><p>WebAuthn failures by user agent.</p>
</li>
<li><p>Cancellation frequency.</p>
</li>
<li><p>Fallback usage rates.</p>
</li>
</ul>
<p>UX metrics are security metrics.</p>
<hr />
<h1 id="heading-8-cryptography-vs-behavior">8. Cryptography vs Behavior</h1>
<p>WebAuthn’s cryptography is solid:</p>
<ul>
<li><p>Public key signatures.</p>
</li>
<li><p>Origin binding.</p>
</li>
<li><p>Replay protection.</p>
</li>
<li><p>Counter tracking.</p>
</li>
</ul>
<p>But if:</p>
<ul>
<li><p>Users disable it.</p>
</li>
<li><p>Enrollment fails.</p>
</li>
<li><p>Recovery is confusing.</p>
</li>
<li><p>Fallback is hidden.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771486979819/253d229d-a106-4919-b203-8743b4251bab.png" alt class="image--center mx-auto" /></p>
<p>Then strong algorithms lose to weak experience.</p>
<p>The most secure system is the one users willingly use.</p>
<hr />
<h1 id="heading-final-reflection">Final Reflection</h1>
<p>Security engineers love to debate:</p>
<ul>
<li><p>Key lengths.</p>
</li>
<li><p>Counter semantics.</p>
</li>
<li><p>Attestation policies.</p>
</li>
</ul>
<p>But in real deployments, the bigger questions are:</p>
<ul>
<li><p>Did the user understand what just happened?</p>
</li>
<li><p>Did the retry flow make sense?</p>
</li>
<li><p>Did cancellation feel safe?</p>
</li>
<li><p>Did fallback feel legitimate?</p>
</li>
<li><p>Did the browser modal align with user expectations?</p>
</li>
</ul>
<p>Browser UX is not decoration layered on top of cryptography.</p>
<p>It is the environment in which cryptography lives.</p>
<p>WebAuthn’s design is brilliant.</p>
<p>But the success of a passwordless-first PWA depends less on elliptic curves — and more on how gracefully your system handles human uncertainty.</p>
<p>Stronger algorithms improve theoretical security.</p>
<p>Clearer UX improves actual security.</p>
<p>And in production systems, actual security is the only kind that matters.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p>→ <strong>How Browser UX Shapes Security More Than Cryptography</strong></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why Passwordless Alone Is Not an Identity Strategy]]></title><description><![CDATA[When teams adopt WebAuthn or FIDO2, the excitement is understandable:

No passwords.

No phishing.

No credential stuffing.

Biometric UX.

Public-key cryptography.


It feels like the final answer.
But WebAuthn answers exactly one question:

Can thi...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy</guid><category><![CDATA[Identity Strategy]]></category><category><![CDATA[Account Recovery]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[Federated Identity]]></category><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[security architecture]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Thu, 19 Feb 2026 04:19:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771474164337/b99e16a5-d967-461d-87ef-fd96a7e58f34.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When teams adopt WebAuthn or FIDO2, the excitement is understandable:</p>
<ul>
<li><p>No passwords.</p>
</li>
<li><p>No phishing.</p>
</li>
<li><p>No credential stuffing.</p>
</li>
<li><p>Biometric UX.</p>
</li>
<li><p>Public-key cryptography.</p>
</li>
</ul>
<p>It feels like the final answer.</p>
<p>But WebAuthn answers exactly one question:</p>
<blockquote>
<p>Can this device prove control of a credential for this origin right now?</p>
</blockquote>
<p>It does not answer:</p>
<ul>
<li><p>Who is this user across systems?</p>
</li>
<li><p>What happens if the device is lost?</p>
</li>
<li><p>How do we bootstrap identity?</p>
</li>
<li><p>How do we link accounts?</p>
</li>
<li><p>How do we recover?</p>
</li>
<li><p>How do we federate across institutions?</p>
</li>
</ul>
<p>Passwordless authentication solves <strong>proof of possession</strong>.</p>
<p>Identity strategy solves <strong>continuity over time</strong>.</p>
<p>Those are different problems.</p>
<hr />
<h1 id="heading-the-illusion-of-pure-passwordless">The Illusion of “Pure Passwordless”</h1>
<p>It’s tempting to imagine a system that:</p>
<ul>
<li><p>Only uses WebAuthn</p>
</li>
<li><p>Has no identity provider</p>
</li>
<li><p>Has no fallback</p>
</li>
<li><p>Has no recovery flow</p>
</li>
</ul>
<p>On paper, that sounds maximally secure.</p>
<p>In reality, it’s brittle.</p>
<p>Let’s walk through real scenarios.</p>
<hr />
<h1 id="heading-scenario-1-device-loss">Scenario 1 — Device Loss</h1>
<p>User registers WebAuthn credential.</p>
<p>All good.</p>
<p>Then:</p>
<ul>
<li><p>Phone is lost.</p>
</li>
<li><p>Laptop is replaced.</p>
</li>
<li><p>Browser storage is cleared.</p>
</li>
</ul>
<p>Now what?</p>
<p>Without fallback:</p>
<ul>
<li><p>The account is inaccessible.</p>
</li>
<li><p>Support must intervene manually.</p>
</li>
<li><p>Or recovery becomes weak (email-only reset).</p>
</li>
</ul>
<p>If recovery is ad hoc, security erodes.</p>
<p>If recovery is absent, usability collapses.</p>
<p>This is why fallback is not compromise — it is necessity.</p>
<hr />
<h1 id="heading-fallback-is-a-design-requirement">Fallback Is a Design Requirement</h1>
<p>Fallback should not mean:</p>
<p>“Use a weaker method.”</p>
<p>It should mean:</p>
<p>“Use an alternate trust anchor.”</p>
<p>In your architecture, that trust anchor was Feide (OIDC).</p>
<p>WebAuthn provided:</p>
<ul>
<li>Device-bound possession proof.</li>
</ul>
<p>Feide provided:</p>
<ul>
<li>Federated identity continuity.</li>
</ul>
<p>That layering is deliberate.</p>
<hr />
<h1 id="heading-passwordless-without-federation-breaks-at-scale">Passwordless Without Federation Breaks at Scale</h1>
<p>In a real system:</p>
<ul>
<li><p>Users change devices.</p>
</li>
<li><p>Users move institutions.</p>
</li>
<li><p>Accounts are deactivated upstream.</p>
</li>
<li><p>Identity policies change.</p>
</li>
</ul>
<p>Without federation:</p>
<ul>
<li><p>You must manage identity lifecycle yourself.</p>
</li>
<li><p>You must build account verification logic.</p>
</li>
<li><p>You must build secure recovery flows.</p>
</li>
<li><p>You must handle identity merging.</p>
</li>
</ul>
<p>That is significantly more complex than integrating an IdP.</p>
<hr />
<h1 id="heading-enrollment-is-identity-design">Enrollment Is Identity Design</h1>
<p>Enrollment is often treated as a one-time setup.</p>
<p>It is not.</p>
<p>Enrollment defines:</p>
<ul>
<li><p>Who is allowed to create a credential?</p>
</li>
<li><p>How is that identity verified?</p>
</li>
<li><p>What trust anchor validates the user at registration?</p>
</li>
</ul>
<p>Example (ASP.NET Core + OIDC bootstrap):</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> externalUserId = claims.FindFirst(<span class="hljs-string">"sub"</span>)?.Value;

<span class="hljs-keyword">var</span> user = <span class="hljs-keyword">await</span> FindOrCreateUser(externalUserId);

<span class="hljs-keyword">if</span> (!user.WebAuthnCredentials.Any())
{
    <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/enable-passwordless"</span>);
}
</code></pre>
<p>Notice what happened:</p>
<ul>
<li><p>OIDC verified identity.</p>
</li>
<li><p>Only then did WebAuthn credential get registered.</p>
</li>
</ul>
<p>WebAuthn did not create identity.</p>
<p>It attached to it.</p>
<p>That ordering matters.</p>
<hr />
<h1 id="heading-recovery-is-where-identity-strategy-is-tested">Recovery Is Where Identity Strategy Is Tested</h1>
<p>The real test of maturity is not login success.</p>
<p>It’s failure recovery.</p>
<p>Lost device flow:</p>
<ol>
<li><p>User authenticates via OIDC.</p>
</li>
<li><p>System validates <code>sub</code> claim.</p>
</li>
<li><p>Existing WebAuthn credentials are revoked.</p>
</li>
<li><p>New device registers fresh credential.</p>
</li>
</ol>
<p>Example revocation logic:</p>
<pre><code class="lang-csharp">_db.WebAuthnCredentials.RemoveRange(user.WebAuthnCredentials);
<span class="hljs-keyword">await</span> _db.SaveChangesAsync();
</code></pre>
<p>Then redirect to registration.</p>
<p>This is structured recovery.</p>
<p>Without OIDC, you would need:</p>
<ul>
<li><p>Email-only verification</p>
</li>
<li><p>Manual admin override</p>
</li>
<li><p>Or permanent account loss</p>
</li>
</ul>
<p>None of those scale securely.</p>
<hr />
<h1 id="heading-device-bound-authentication-is-not-portable-identity">Device-Bound Authentication Is Not Portable Identity</h1>
<p>WebAuthn credentials are bound to:</p>
<ul>
<li><p>Origin</p>
</li>
<li><p>Device</p>
</li>
<li><p>RP ID</p>
</li>
</ul>
<p>They are intentionally non-transferable.</p>
<p>That’s why they’re secure.</p>
<p>But identity is portable.</p>
<p>Identity must:</p>
<ul>
<li><p>Survive device turnover</p>
</li>
<li><p>Integrate with external systems</p>
</li>
<li><p>Be recognized across services</p>
</li>
</ul>
<p>That’s federation.</p>
<hr />
<h1 id="heading-federation-is-not-the-enemy-of-passwordless">Federation Is Not the Enemy of Passwordless</h1>
<p>There’s a misconception:</p>
<p>“If I use OIDC fallback, I weaken passwordless.”</p>
<p>That only happens when fallback bypasses verification.</p>
<p>In your architecture:</p>
<ul>
<li><p>OIDC never created a session automatically.</p>
</li>
<li><p>Backend validated ID token.</p>
</li>
<li><p>Internal user mapping occurred.</p>
</li>
<li><p>HTTP-only cookie issued by your system.</p>
</li>
</ul>
<p>OIDC proved identity.</p>
<p>WebAuthn proved possession.</p>
<p>The trust boundaries remained intact.</p>
<hr />
<h1 id="heading-architectural-maturity-means-layering">Architectural Maturity Means Layering</h1>
<p>Let’s describe the trust model clearly.</p>
<p>Layer 1: Federation (Feide)</p>
<ul>
<li><p>Asserts institutional identity</p>
</li>
<li><p>Manages upstream lifecycle</p>
</li>
<li><p>Provides recovery</p>
</li>
</ul>
<p>Layer 2: Passwordless (WebAuthn)</p>
<ul>
<li><p>Proves device possession</p>
</li>
<li><p>Phishing-resistant</p>
</li>
<li><p>Per-origin authentication</p>
</li>
</ul>
<p>Layer 3: Session (HTTP-only cookie)</p>
<ul>
<li><p>Server-controlled</p>
</li>
<li><p>Revocable</p>
</li>
<li><p>Protected from JS</p>
</li>
</ul>
<p>Layer 4: Authorization</p>
<ul>
<li><p>Application-level access control</p>
</li>
<li><p>Role management</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771474693452/bebd416c-d613-48f9-9f58-59f7482ac929.png" alt class="image--center mx-auto" /></p>
<p>Each layer solves a different problem.</p>
<p>No single layer replaces the others.</p>
<hr />
<h1 id="heading-the-real-question">The Real Question</h1>
<p>When designing authentication, the mature question is not:</p>
<p>“How do we eliminate passwords?”</p>
<p>It is:</p>
<p>“How do we design identity continuity over time?”</p>
<p>Passwordless improves authentication strength.</p>
<p>Federation ensures identity stability.</p>
<p>Together, they create resilience.</p>
<hr />
<h1 id="heading-what-happens-if-you-ignore-this">What Happens If You Ignore This</h1>
<p>If passwordless stands alone:</p>
<ul>
<li><p>Enrollment becomes fragile.</p>
</li>
<li><p>Recovery becomes weak.</p>
</li>
<li><p>Identity merging becomes manual.</p>
</li>
<li><p>Device loss becomes support nightmare.</p>
</li>
<li><p>Organizational integration becomes impossible.</p>
</li>
</ul>
<p>The system becomes secure in theory, brittle in reality.</p>
<hr />
<h1 id="heading-the-strategic-insight">The Strategic Insight</h1>
<p>Passwordless is a mechanism.</p>
<p>Identity strategy is a lifecycle.</p>
<p>Mechanisms can be secure.</p>
<p>Lifecycles must be resilient.</p>
<p>Your architecture works because:</p>
<ul>
<li><p>It does not idolize passwordless.</p>
</li>
<li><p>It positions WebAuthn as primary.</p>
</li>
<li><p>It retains OIDC as structured fallback.</p>
</li>
<li><p>It treats recovery as planned, not emergency.</p>
</li>
<li><p>It separates identity from possession.</p>
</li>
</ul>
<p>That separation is the mark of architectural maturity.</p>
<hr />
<h1 id="heading-final-reflection">Final Reflection</h1>
<p>Passwordless alone is not enough.</p>
<p>Not because it’s weak.</p>
<p>But because identity is larger than authentication.</p>
<p>A secure system must answer:</p>
<ul>
<li><p>Who are you?</p>
</li>
<li><p>Can you prove it now?</p>
</li>
<li><p>What happens if you lose your device?</p>
</li>
<li><p>How do we recognize you tomorrow?</p>
</li>
<li><p>How do we integrate with your organization?</p>
</li>
</ul>
<p>WebAuthn answers one of those questions exceptionally well.</p>
<p>Federation answers the rest.</p>
<p>Designing both — intentionally — is what turns passwordless from a feature into an identity strategy.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p>→ <strong>Why Passwordless Alone Is Not an Identity Strategy</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Passwordless: What Worked, What Didn’t, What I’d Change]]></title><description><![CDATA[When designing a passwordless-first PWA architecture, the diagram looks elegant.
In production, elegance collides with:

Browser inconsistencies

Institutional identity constraints

Support tickets

Device lifecycle chaos

Monitoring blind spots


Le...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change</guid><category><![CDATA[Production Lessons]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[Feide]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[SQL Server]]></category><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[SecurityEngineering]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[asp.net core]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Thu, 19 Feb 2026 03:14:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771470850029/85055752-ab3e-493b-a76f-879159e4180b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When designing a passwordless-first PWA architecture, the diagram looks elegant.</p>
<p>In production, elegance collides with:</p>
<ul>
<li><p>Browser inconsistencies</p>
</li>
<li><p>Institutional identity constraints</p>
</li>
<li><p>Support tickets</p>
</li>
<li><p>Device lifecycle chaos</p>
</li>
<li><p>Monitoring blind spots</p>
</li>
</ul>
<p>Let’s break it down honestly.</p>
<hr />
<h1 id="heading-what-worked">What Worked</h1>
<h2 id="heading-1-webauthn-as-primary-authentication">1️⃣ WebAuthn as Primary Authentication</h2>
<p>This worked better than expected.</p>
<p>Users quickly adapted to:</p>
<ul>
<li><p>Fingerprint</p>
</li>
<li><p>Face recognition</p>
</li>
<li><p>Device PIN</p>
</li>
</ul>
<p>Support requests about “forgot password” dropped to zero — because passwords were gone.</p>
<p>The combination of:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> _fido2.MakeAssertionAsync(...)
</code></pre>
<p>and:</p>
<pre><code class="lang-csharp">HttpContext.SignInAsync(<span class="hljs-string">"Cookies"</span>, principal);
</code></pre>
<p>proved stable and predictable once encoding and session handling were correct.</p>
<p>The strongest success signal:</p>
<p>No phishing-related login issues after deployment.</p>
<p>That is not common.</p>
<h2 id="heading-2-http-only-cookie-sessions">2️⃣ HTTP-only Cookie Sessions</h2>
<p>Avoiding JWT-in-localStorage was absolutely the right call.</p>
<pre><code class="lang-csharp">options.Cookie.HttpOnly = <span class="hljs-literal">true</span>;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
</code></pre>
<p>Benefits:</p>
<ul>
<li><p>XSS impact minimized</p>
</li>
<li><p>Simpler revocation model</p>
</li>
<li><p>Clear session lifetime control</p>
</li>
</ul>
<p>Operationally, this reduced attack surface significantly.</p>
<h2 id="heading-3-clear-decision-tree">3️⃣ Clear Decision Tree</h2>
<p>My initial flowchart saved me.</p>
<p>Because when things broke, I always knew which branch was responsible:</p>
<ul>
<li><p>WebAuthn failure?</p>
</li>
<li><p>OIDC fallback?</p>
</li>
<li><p>Session misconfiguration?</p>
</li>
<li><p>Credential lifecycle issue?</p>
</li>
</ul>
<p>That clarity matters more than people realize.</p>
<hr />
<h1 id="heading-trade-offs-i-accepted-knowingly">Trade-offs I Accepted Knowingly</h1>
<h2 id="heading-1-no-attestation-verification">1️⃣ No Attestation Verification</h2>
<pre><code class="lang-csharp">AttestationConveyancePreference.None
</code></pre>
<p>Trade-off:</p>
<ul>
<li><p>No hardware manufacturer validation</p>
</li>
<li><p>No enforcement of hardware-backed keys</p>
</li>
</ul>
<p>Why I accepted it:</p>
<ul>
<li><p>Lower operational complexity</p>
</li>
<li><p>Better privacy posture</p>
</li>
<li><p>Reduced metadata dependency</p>
</li>
</ul>
<p>In institutional context, identity assurance was already upstream via Feide.</p>
<h2 id="heading-2-preferred-instead-of-required-user-verification">2️⃣ Preferred Instead of Required User Verification</h2>
<pre><code class="lang-csharp">UserVerificationRequirement.Preferred
</code></pre>
<p>Trade-off:</p>
<ul>
<li><p>Allows authenticators without biometric enforcement</p>
</li>
<li><p>Slightly lower strictness</p>
</li>
</ul>
<p>Why:</p>
<ul>
<li><p>Broader device compatibility</p>
</li>
<li><p>Fewer user lockouts</p>
</li>
<li><p>Reduced friction in older hardware environments</p>
</li>
</ul>
<p>Security posture was balanced against accessibility.</p>
<h2 id="heading-3-no-offline-authentication">3️⃣ No Offline Authentication</h2>
<p>PWA expectation:<br />“It’s installed. It should work offline.”</p>
<p>Reality:<br />WebAuthn requires server challenge.</p>
<p>I chose not to simulate offline authentication using cached tokens beyond session lifetime.</p>
<p>Trade-off:</p>
<ul>
<li><p>Some UX friction</p>
</li>
<li><p>Stronger trust model</p>
</li>
</ul>
<p>Security &gt; illusion of offline login.</p>
<hr />
<h1 id="heading-what-looked-good-on-paper-but-failed-in-reality">What Looked Good on Paper But Failed in Reality</h1>
<h2 id="heading-1-users-will-immediately-register-passwordless">1️⃣ “Users Will Immediately Register Passwordless”</h2>
<p>They didn’t.</p>
<p>Even after OIDC login, many skipped enabling passwordless.</p>
<p>The elegant flow:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">if</span> (!user.Credentials.Any())
    <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/enable-passwordless"</span>);
</code></pre>
<p>In reality:<br />Users ignored prompts.</p>
<p>Lesson:<br />Make passwordless enrollment prominent and incentivized.</p>
<h2 id="heading-2-counter-strictness">2️⃣ Counter Strictness</h2>
<p>Initially:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">if</span> (result.Counter &lt;= storedCounter)
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> SecurityException(<span class="hljs-string">"Possible cloned authenticator"</span>);
</code></pre>
<p>This caused false positives.</p>
<p>Some authenticators:</p>
<ul>
<li><p>Always returned 0</p>
</li>
<li><p>Didn’t increment reliably</p>
</li>
</ul>
<p>Lesson:<br />Spec compliance is messier than the spec implies.</p>
<p>Relaxed logic to handle zero counters more intelligently.</p>
<h2 id="heading-3-browser-error-consistency">3️⃣ Browser Error Consistency</h2>
<p>I assumed:</p>
<p>“All browsers implement WebAuthn uniformly.”</p>
<p>Reality:</p>
<ul>
<li><p>Different error messages</p>
</li>
<li><p>Different cancellation behaviors</p>
</li>
<li><p>Slight timing differences</p>
</li>
</ul>
<p>VueJS error handling needed refinement:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">catch</span> (err) {
  <span class="hljs-keyword">if</span> (err.name === <span class="hljs-string">"NotAllowedError"</span>) {
    showRetry();
  } <span class="hljs-keyword">else</span> {
    showFallbackOption();
  }
}
</code></pre>
<p>UX required careful branching.</p>
<hr />
<h1 id="heading-operational-lessons">Operational Lessons</h1>
<h2 id="heading-1-logging-matters-more-than-crypto">1️⃣ Logging Matters More Than Crypto</h2>
<p>You need logs for:</p>
<ul>
<li><p>Challenge generation</p>
</li>
<li><p>Assertion verification result</p>
</li>
<li><p>Counter updates</p>
</li>
<li><p>OIDC callback mapping</p>
</li>
<li><p>Session creation</p>
</li>
</ul>
<p>Example structured logging:</p>
<pre><code class="lang-csharp">_logger.LogInformation(<span class="hljs-string">"WebAuthn assertion verified for user {UserId}, counter updated to {Counter}"</span>,
    user.Id, result.Counter);
</code></pre>
<p>Without this, debugging failures becomes guesswork.</p>
<h2 id="heading-2-monitoring-authentication-metrics">2️⃣ Monitoring Authentication Metrics</h2>
<p>Track:</p>
<ul>
<li><p>WebAuthn success rate</p>
</li>
<li><p>WebAuthn failure rate</p>
</li>
<li><p>OIDC fallback frequency</p>
</li>
<li><p>Credential registrations per day</p>
</li>
<li><p>Counter mismatch events</p>
</li>
</ul>
<p>These reveal patterns:</p>
<ul>
<li><p>Device compatibility issues</p>
</li>
<li><p>Misconfiguration</p>
</li>
<li><p>User confusion</p>
</li>
</ul>
<p>Authentication is not “set and forget.”</p>
<h2 id="heading-3-support-edge-cases">3️⃣ Support Edge Cases</h2>
<p>Real tickets included:</p>
<ul>
<li><p>“My fingerprint stopped working after OS update.”</p>
</li>
<li><p>“I cleared my browser data and now can’t log in.”</p>
</li>
<li><p>“I logged in via Feide but it says no account.”</p>
</li>
</ul>
<p>Each required:</p>
<ul>
<li><p>Clear recovery path</p>
</li>
<li><p>Transparent error messaging</p>
</li>
<li><p>Internal documentation</p>
</li>
</ul>
<p>Edge cases are not rare. They are normal.</p>
<h2 id="heading-4-account-linking-confusion">4️⃣ Account Linking Confusion</h2>
<p>Some users had:</p>
<ul>
<li><p>Multiple institutional identities</p>
</li>
<li><p>Email changes</p>
</li>
<li><p>Duplicate accounts</p>
</li>
</ul>
<p>Relying solely on email would have been disastrous.</p>
<p>Using <code>sub</code> claim for linking was critical:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> externalUserId = claims.FindFirst(<span class="hljs-string">"sub"</span>)?.Value;
</code></pre>
<p>Stable identifiers are everything.</p>
<hr />
<h1 id="heading-what-i-would-change">What I Would Change</h1>
<h2 id="heading-1-stronger-enrollment-enforcement">1️⃣ Stronger Enrollment Enforcement</h2>
<p>Instead of optional passwordless enablement:</p>
<p>I would require it after first successful OIDC login.</p>
<p>Security adoption improves when it’s default, not optional.</p>
<h2 id="heading-2-better-device-management-ui">2️⃣ Better Device Management UI</h2>
<p>Users should see:</p>
<ul>
<li><p>List of registered devices</p>
</li>
<li><p>Last used timestamp</p>
</li>
<li><p>Revoke option</p>
</li>
</ul>
<p>Backend model already supports it:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> WebAuthnCredentials <span class="hljs-keyword">WHERE</span> UserId = @UserId
</code></pre>
<p>But UX should surface it more clearly.</p>
<h2 id="heading-3-structured-monitoring-dashboard">3️⃣ Structured Monitoring Dashboard</h2>
<p>Real-time visibility into:</p>
<ul>
<li><p>Assertion failures</p>
</li>
<li><p>Counter mismatches</p>
</li>
<li><p>OIDC errors</p>
</li>
</ul>
<p>Would reduce reactive debugging.</p>
<h2 id="heading-4-automated-credential-health-checks">4️⃣ Automated Credential Health Checks</h2>
<p>Periodic validation:</p>
<ul>
<li><p>Detect stale counters</p>
</li>
<li><p>Detect inactive credentials</p>
</li>
<li><p>Flag suspicious behavior</p>
</li>
</ul>
<p>WebAuthn gives strong primitives. Monitoring must match.</p>
<hr />
<h1 id="heading-the-big-lesson">The Big Lesson</h1>
<p>The hardest part of passwordless authentication is not cryptography.</p>
<p>It is lifecycle management.</p>
<p>WebAuthn works.</p>
<p>OIDC works.</p>
<p>HTTP-only cookies work.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771470354356/496889c4-7852-4d1c-9064-05fc6576dd08.png" alt class="image--center mx-auto" /></p>
<p>But the real challenge is designing:</p>
<ul>
<li><p>Failure handling</p>
</li>
<li><p>Device transitions</p>
</li>
<li><p>Recovery paths</p>
</li>
<li><p>Operational visibility</p>
</li>
</ul>
<p>Security architecture is not proven at deployment.</p>
<p>It is proven over time.</p>
<hr />
<h1 id="heading-final-reflection">Final Reflection</h1>
<p>If I rebuilt this system:</p>
<ul>
<li><p>I would keep passwordless-first.</p>
</li>
<li><p>I would keep Feide federation.</p>
</li>
<li><p>I would keep server-controlled sessions.</p>
</li>
<li><p>I would invest earlier in monitoring and enrollment enforcement.</p>
</li>
</ul>
<p>What surprised me most?</p>
<p>How much calmer authentication became once passwords were gone.</p>
<p>No resets.<br />No reuse.<br />No phishing alerts.</p>
<p>Just possession proof + federated identity continuity.</p>
<p>That combination feels less like a feature and more like an upgrade to the trust model of the application itself.</p>
<p>And that, ultimately, was the goal of the entire series. This concludes the series, but you can still check out my next optional extras articles next.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p>→ <strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Integrating OIDC (Feide) as Fallback and Recovery]]></title><description><![CDATA[WebAuthn gave us phishing-resistant, device-bound authentication.But devices get lost. Browsers reset. Users switch laptops. Institutions manage identities centrally.
That’s where OIDC (Feide) enters — not as a competitor to passwordless, but as stru...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery</guid><category><![CDATA[OpenID Connect]]></category><category><![CDATA[OIDC]]></category><category><![CDATA[Feide]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[Federated Identity]]></category><category><![CDATA[account linking]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[asp.net core]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Wed, 18 Feb 2026 02:59:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771383125974/0ea35be2-6c8d-465b-94b8-72ef335d3470.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>WebAuthn gave us phishing-resistant, device-bound authentication.<br />But devices get lost. Browsers reset. Users switch laptops. Institutions manage identities centrally.</p>
<p>That’s where <strong>OIDC (Feide)</strong> enters — not as a competitor to passwordless, but as structural support.</p>
<p>This article walks through my real implementation:</p>
<ul>
<li><p><strong>Frontend:</strong> VueJS PWA</p>
</li>
<li><p><strong>Backend:</strong> ASP.NET Core</p>
</li>
<li><p><strong>Database:</strong> SQL Server</p>
</li>
<li><p><strong>Passwordless:</strong> fido2-net-lib</p>
</li>
<li><p><strong>Federation:</strong> OpenID Connect (Feide)</p>
</li>
<li><p><strong>Session:</strong> HTTP-only cookie</p>
</li>
</ul>
<p>And we’ll focus on four things:</p>
<ol>
<li><p>What can Feide bring to the table</p>
</li>
<li><p>How OIDC fits without undermining WebAuthn</p>
</li>
<li><p>Security boundaries between IdP and my system</p>
</li>
<li><p>Account linking in practice</p>
</li>
</ol>
<h3 id="heading-disclaimer">Disclaimer</h3>
<p><em>This article describes architectural patterns and technical approaches based on a real-world implementation. All examples, code snippets, and flow descriptions have been generalized and simplified for educational purposes. No proprietary business logic, confidential configurations, credentials, or organization-specific details are disclosed. The focus is strictly on publicly documented standards (WebAuthn, OIDC) and implementation patterns within a standard VueJS + ASP.NET Core + SQL Server stack.</em></p>
<hr />
<h1 id="heading-what-can-feide-bring-to-the-table">What can Feide bring to the table</h1>
<p><a target="_blank" href="https://docs.feide.no/general/feide_overview.html">Feide</a> is widely used in Norwegian education and research sectors. That matters for three reasons:</p>
<h3 id="heading-1-institutional-identity-already-exists">1️⃣ Institutional Identity Already Exists</h3>
<p>Users already have:</p>
<ul>
<li><p>A managed identity</p>
</li>
<li><p>Centralized credential lifecycle</p>
</li>
<li><p>Organizational trust</p>
</li>
</ul>
<p>Recreating identity inside my PWA would be redundant and weaker.</p>
<h3 id="heading-2-compliance-amp-governance">2️⃣ Compliance &amp; Governance</h3>
<p>Institutional IdPs typically enforce:</p>
<ul>
<li><p>MFA policies</p>
</li>
<li><p>Password strength</p>
</li>
<li><p>Account revocation</p>
</li>
<li><p>Auditing</p>
</li>
</ul>
<p>By integrating Feide, my system inherits that upstream assurance without storing passwords.</p>
<h3 id="heading-3-recovery-and-bootstrap">3️⃣ Recovery and Bootstrap</h3>
<p>WebAuthn is device-bound.</p>
<p>Feide provides:</p>
<ul>
<li><p>Cross-device identity continuity</p>
</li>
<li><p>Secure account recovery</p>
</li>
<li><p>Bootstrap trust for new devices</p>
</li>
</ul>
<hr />
<h1 id="heading-how-oidc-fits-without-undermining-passwordless">How OIDC Fits Without Undermining Passwordless</h1>
<p>The common fear:</p>
<blockquote>
<p>“If I add OIDC fallback, doesn’t that weaken passwordless?”</p>
</blockquote>
<p>Only if fallback is careless.</p>
<p>My architecture enforces this model:</p>
<ul>
<li><p>WebAuthn = primary authentication</p>
</li>
<li><p>Feide OIDC = bootstrap + recovery</p>
</li>
<li><p>HTTP-only cookie = session integrity</p>
</li>
<li><p>SQL Server = credential persistence</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771382401976/436634c8-3781-4665-98b2-a3fe4b13ec9c.png" alt class="image--center mx-auto" /></p>
<p>Feide does not authenticate users inside my system directly.</p>
<p>Feide asserts identity.</p>
<p>WebAuthn proves device possession.</p>
<p>Those are different trust layers.</p>
<hr />
<h1 id="heading-real-oidc-integration-aspnet-core">Real OIDC Integration (ASP.NET Core)</h1>
<p>My implemented Authorization Code flow with PKCE.</p>
<h3 id="heading-oidc-configuration">OIDC Configuration</h3>
<pre><code class="lang-csharp">services.AddAuthentication(options =&gt;
{
    options.DefaultScheme = <span class="hljs-string">"Cookies"</span>;
    options.DefaultChallengeScheme = <span class="hljs-string">"oidc"</span>;
})
.AddCookie(<span class="hljs-string">"Cookies"</span>, options =&gt;
{
    options.Cookie.HttpOnly = <span class="hljs-literal">true</span>;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Lax;
})
.AddOpenIdConnect(<span class="hljs-string">"oidc"</span>, options =&gt;
{
    options.Authority = <span class="hljs-string">"https://auth.feide.no"</span>;
    options.ClientId = Configuration[<span class="hljs-string">"Feide:ClientId"</span>];
    options.ClientSecret = Configuration[<span class="hljs-string">"Feide:ClientSecret"</span>];
    options.ResponseType = <span class="hljs-string">"code"</span>;
    options.SaveTokens = <span class="hljs-literal">false</span>;
    options.GetClaimsFromUserInfoEndpoint = <span class="hljs-literal">true</span>;

    options.Scope.Add(<span class="hljs-string">"openid"</span>);
    options.Scope.Add(<span class="hljs-string">"profile"</span>);
    options.Scope.Add(<span class="hljs-string">"email"</span>);

    options.TokenValidationParameters.NameClaimType = <span class="hljs-string">"name"</span>;
});
</code></pre>
<p>Important detail:</p>
<pre><code class="lang-csharp">options.SaveTokens = <span class="hljs-literal">false</span>;
</code></pre>
<p>You do not store IdP tokens in the browser.</p>
<p>You convert identity into a server-controlled session.</p>
<hr />
<h1 id="heading-oidc-callback-flow">OIDC Callback Flow</h1>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpGet(<span class="hljs-meta-string">"callback"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">Callback</span>(<span class="hljs-params"></span>)</span>
{
    <span class="hljs-keyword">var</span> authenticateResult = <span class="hljs-keyword">await</span> HttpContext.AuthenticateAsync(<span class="hljs-string">"oidc"</span>);

    <span class="hljs-keyword">if</span> (!authenticateResult.Succeeded)
        <span class="hljs-keyword">return</span> Unauthorized();

    <span class="hljs-keyword">var</span> externalUserId = authenticateResult.Principal.FindFirst(<span class="hljs-string">"sub"</span>)?.Value;

    <span class="hljs-keyword">var</span> user = <span class="hljs-keyword">await</span> FindOrCreateUser(externalUserId);

    SignInUser(user.Id);

    <span class="hljs-keyword">if</span> (!user.WebAuthnCredentials.Any())
        <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/enable-passwordless"</span>);

    <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/dashboard"</span>);
}
</code></pre>
<p>This is critical:</p>
<ul>
<li><p>Feide proves identity.</p>
</li>
<li><p>The system maps that identity to internal user record.</p>
</li>
<li><p>The system issues session cookie.</p>
</li>
</ul>
<p>The IdP does not create sessions in my system.</p>
<hr />
<h1 id="heading-security-boundaries-between-oidc-and-my-system">Security Boundaries Between OIDC and My System</h1>
<p>Understanding boundaries prevents architectural confusion.</p>
<h2 id="heading-what-oidc-is-responsible-for">What OIDC Is Responsible For</h2>
<ul>
<li><p>Authenticating the user upstream</p>
</li>
<li><p>Issuing ID tokens</p>
</li>
<li><p>Managing institutional identity lifecycle</p>
</li>
<li><p>Enforcing upstream MFA policies</p>
</li>
</ul>
<h2 id="heading-what-my-system-is-responsible-for">What My System Is Responsible For</h2>
<ul>
<li><p>Mapping external identity (<code>sub</code>) to internal user</p>
</li>
<li><p>Managing WebAuthn credentials</p>
</li>
<li><p>Verifying FIDO2 assertions</p>
</li>
<li><p>Issuing and invalidating session cookies</p>
</li>
<li><p>Authorization within my application</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771382763546/5a5fa933-6a2e-48b3-a819-28f7dc85eb4b.png" alt class="image--center mx-auto" /></p>
<p>OIDC is not trusted to:</p>
<ul>
<li><p>Authorize application actions</p>
</li>
<li><p>Manage WebAuthn devices</p>
</li>
<li><p>Maintain the session integrity</p>
</li>
</ul>
<p>Trust is layered, not delegated.</p>
<hr />
<h1 id="heading-account-linking-considerations">Account Linking Considerations</h1>
<p>This is where real complexity lives.</p>
<p>OIDC provides:</p>
<pre><code class="lang-javascript">{
  <span class="hljs-string">"sub"</span>: <span class="hljs-string">"abcd1234"</span>,
  <span class="hljs-string">"email"</span>: <span class="hljs-string">"user@example.edu"</span>
}
</code></pre>
<p>But what if:</p>
<ul>
<li><p>Email changes?</p>
</li>
<li><p>User logs in with different institutional account?</p>
</li>
<li><p>Duplicate local account exists?</p>
</li>
</ul>
<p>You must choose a stable linking strategy.</p>
<h2 id="heading-recommended-linking-model">Recommended Linking Model</h2>
<p>Use <code>sub</code> as the primary external identifier.</p>
<p>Database model:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> ExternalLogins (
    <span class="hljs-keyword">Id</span> UNIQUEIDENTIFIER PRIMARY <span class="hljs-keyword">KEY</span>,
    UserId UNIQUEIDENTIFIER <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    Provider <span class="hljs-keyword">NVARCHAR</span>(<span class="hljs-number">50</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    ExternalSubject <span class="hljs-keyword">NVARCHAR</span>(<span class="hljs-number">255</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>
);
</code></pre>
<p>Mapping logic:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> externalLogin = <span class="hljs-keyword">await</span> _db.ExternalLogins
    .FirstOrDefaultAsync(x =&gt;
        x.Provider == <span class="hljs-string">"Feide"</span> &amp;&amp;
        x.ExternalSubject == externalUserId);

<span class="hljs-keyword">if</span> (externalLogin == <span class="hljs-literal">null</span>)
{
    <span class="hljs-comment">// First login → create link</span>
    <span class="hljs-keyword">var</span> user = CreateNewUser();
    _db.ExternalLogins.Add(<span class="hljs-keyword">new</span> ExternalLogin {
        UserId = user.Id,
        Provider = <span class="hljs-string">"Feide"</span>,
        ExternalSubject = externalUserId
    });
}
</code></pre>
<p>Never rely solely on email for linking.</p>
<p>Emails change. <code>sub</code> should not.</p>
<hr />
<h1 id="heading-recovery-flow-using-feide">Recovery Flow Using Feide</h1>
<p>Lost device scenario:</p>
<ol>
<li><p>User clicks “Login with Feide”</p>
</li>
<li><p>OIDC completes</p>
</li>
<li><p>Identity verified</p>
</li>
<li><p>System invalidates old WebAuthn credentials</p>
</li>
<li><p>User registers new credential</p>
</li>
</ol>
<p>Example revocation:</p>
<pre><code class="lang-csharp">_db.WebAuthnCredentials.RemoveRange(user.WebAuthnCredentials);
<span class="hljs-keyword">await</span> _db.SaveChangesAsync();
</code></pre>
<p>Then redirect to registration.</p>
<p>Recovery is structured. Not improvised.</p>
<hr />
<h1 id="heading-why-this-does-not-undermine-passwordless">Why This Does Not Undermine Passwordless</h1>
<p>Weak fallback undermines security when:</p>
<ul>
<li><p>It bypasses verification</p>
</li>
<li><p>It skips policy</p>
</li>
<li><p>It exists only as emergency shortcut</p>
</li>
</ul>
<p>My implementation ensures:</p>
<ul>
<li><p>OIDC must complete successfully</p>
</li>
<li><p>Session is server-issued</p>
</li>
<li><p>WebAuthn remains primary method</p>
</li>
<li><p>Registration after OIDC is explicit</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771383053155/e3ce863b-1f1d-4178-96bf-a0e6de19693f.png" alt class="image--center mx-auto" /></p>
<p>This maintains assurance.</p>
<hr />
<h1 id="heading-vuejs-pwa-integration">VueJS PWA Integration</h1>
<p>From frontend:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loginWithFeide</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">window</span>.location.href = <span class="hljs-string">"/api/auth/feide-login"</span>;
}
</code></pre>
<p>No tokens stored client-side.<br />No JWT in localStorage.<br />No client-managed identity state.</p>
<p>The PWA only reacts to session cookie.</p>
<p>This keeps attack surface small.</p>
<hr />
<h1 id="heading-what-this-architecture-achieves">What This Architecture Achieves</h1>
<p>By combining:</p>
<ul>
<li><p>WebAuthn (device-bound proof)</p>
</li>
<li><p>Feide OIDC (identity continuity)</p>
</li>
<li><p>SQL Server (credential persistence)</p>
</li>
<li><p>HTTP-only cookies (session security)</p>
</li>
</ul>
<p>You achieve:</p>
<ul>
<li><p>Phishing resistance</p>
</li>
<li><p>Device lifecycle resilience</p>
</li>
<li><p>Institutional identity integration</p>
</li>
<li><p>Controlled fallback</p>
</li>
<li><p>Clear trust boundaries</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771383362636/e4304380-fee7-4b23-b891-0582eb1f9de5.png" alt class="image--center mx-auto" /></p>
<p>Most importantly:</p>
<p>You avoid false dichotomy.</p>
<p>This is not:</p>
<p>“Passwordless vs Federation.”</p>
<p>It is:</p>
<p>“Passwordless for authentication. Federation for identity continuity.”</p>
<hr />
<h1 id="heading-final-reflection">Final Reflection</h1>
<p>Integrating OIDC did not weaken the system.</p>
<p>It completed it.</p>
<p>WebAuthn without federation is brittle.<br />Federation without WebAuthn is phishable.</p>
<p>Together, they form a layered trust architecture.</p>
<p>In the next article, we’ll examine operational lessons learned after deploying this combined system — including monitoring, auditing, and real-world behavioral patterns that only surface after production traffic begins.</p>
<p>Because authentication design doesn’t end at implementation.</p>
<p>It evolves under pressure.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p>→ <strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Implementing WebAuthn in Practice]]></title><description><![CDATA[WebAuthn looks deceptively simple at a high level:

Generate challenge

Call browser API

Verify signature

Done


In practice, it is not that simple.
WebAuthn is cryptographically elegant but operationally unforgiving.Small mistakes create subtle se...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice</guid><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[fido2-net-lib]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[SQL Server]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[public-key cryptgraphy]]></category><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Application Security]]></category><category><![CDATA[asp.net core]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Tue, 17 Feb 2026 08:38:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771316072943/5198e555-e9f7-469e-a948-0182afe23005.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>WebAuthn looks deceptively simple at a high level:</p>
<ul>
<li><p>Generate challenge</p>
</li>
<li><p>Call browser API</p>
</li>
<li><p>Verify signature</p>
</li>
<li><p>Done</p>
</li>
</ul>
<p>In practice, it is not that simple.</p>
<p>WebAuthn is cryptographically elegant but operationally unforgiving.<br />Small mistakes create subtle security gaps or inexplicable failures.</p>
<p>This article walks through:</p>
<ul>
<li><p>The tooling used</p>
</li>
<li><p>The data model design</p>
</li>
<li><p>Real code from ASP.NET Core + VueJS</p>
</li>
<li><p>Common pitfalls</p>
</li>
<li><p>And what surprised me during implementation</p>
</li>
</ul>
<h3 id="heading-disclaimer">Disclaimer</h3>
<p><em>This article describes architectural patterns and technical approaches based on a real-world implementation. All examples, code snippets, and flow descriptions have been generalized and simplified for educational purposes. No proprietary business logic, confidential configurations, credentials, or organization-specific details are disclosed. The focus is strictly on publicly documented standards (WebAuthn, OIDC) and implementation patterns within a standard VueJS + ASP.NET Core + SQL Server stack.</em></p>
<hr />
<h1 id="heading-tooling-used">Tooling Used</h1>
<h2 id="heading-backend-fido2-net-lib">Backend: <code>fido2-net-lib</code></h2>
<p>For .NET Core, <a target="_blank" href="https://github.com/passwordless-lib/fido2-net-lib"><code>fido2-net-lib</code></a> is one of the most mature and spec-compliant WebAuthn libraries available.</p>
<p>It handles:</p>
<ul>
<li><p>Challenge generation</p>
</li>
<li><p>Attestation verification</p>
</li>
<li><p>Assertion verification</p>
</li>
<li><p>Counter validation</p>
</li>
<li><p>Origin validation</p>
</li>
<li><p>Credential parsing</p>
</li>
</ul>
<p>Initialization:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> fido2 = <span class="hljs-keyword">new</span> Fido2(<span class="hljs-keyword">new</span> Fido2Configuration
{
    ServerDomain = <span class="hljs-string">"yourdomain.com"</span>,
    ServerName = <span class="hljs-string">"Your App"</span>,
    Origin = <span class="hljs-string">"https://yourdomain.com"</span>
});
</code></pre>
<p>The important realization:</p>
<p>The library handles cryptography —<br />You must handle state.</p>
<h2 id="heading-frontend-native-webauthn-api">Frontend: Native WebAuthn API</h2>
<p>In VueJS, no heavy library was required.<br />The browser already implements <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">WebAuthn</a>.</p>
<p>Registration:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> credential = <span class="hljs-keyword">await</span> navigator.credentials.create({
  <span class="hljs-attr">publicKey</span>: options
});
</code></pre>
<p>Authentication:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> assertion = <span class="hljs-keyword">await</span> navigator.credentials.get({
  <span class="hljs-attr">publicKey</span>: options
});
</code></pre>
<p>However:</p>
<p><mark>You must convert Base64URL fields correctly between server and client.</mark></p>
<p>This is one of the first places things break.</p>
<hr />
<h1 id="heading-data-model-design-sql-server">Data Model Design (SQL Server)</h1>
<p>This is where real decisions matter.</p>
<p>A WebAuthn credential is not just an ID.</p>
<p>Here’s the simplified SQL model:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> WebAuthnCredentials (
    <span class="hljs-keyword">Id</span> UNIQUEIDENTIFIER PRIMARY <span class="hljs-keyword">KEY</span>,
    UserId UNIQUEIDENTIFIER <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    CredentialId VARBINARY(<span class="hljs-keyword">MAX</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    PublicKey VARBINARY(<span class="hljs-keyword">MAX</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    SignatureCounter <span class="hljs-built_in">BIGINT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    CreatedAt DATETIME2 <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">SYSUTCDATETIME</span>()
);
</code></pre>
<h3 id="heading-why-varbinary">Why VARBINARY?</h3>
<p>Because:</p>
<ul>
<li><p>Credential IDs are binary.</p>
</li>
<li><p>Public keys are binary (COSE format).</p>
</li>
<li><p>Storing them as strings introduces encoding risk.</p>
</li>
</ul>
<h3 id="heading-why-store-signaturecounter">Why store SignatureCounter?</h3>
<p>The counter protects against cloned authenticators.</p>
<p>If the new counter ≤ stored counter, something is wrong.</p>
<p>WebAuthn security is incomplete without counter tracking.</p>
<hr />
<h1 id="heading-registration-flow-real-implementation">Registration Flow (Real Implementation)</h1>
<h2 id="heading-step-1-generate-options">Step 1: Generate Options</h2>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpPost(<span class="hljs-meta-string">"register-options"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> IActionResult <span class="hljs-title">RegisterOptions</span>(<span class="hljs-params"></span>)</span>
{
    <span class="hljs-keyword">var</span> user = GetCurrentUser();

    <span class="hljs-keyword">var</span> options = _fido2.RequestNewCredential(
        <span class="hljs-keyword">new</span> Fido2User
        {
            Id = Encoding.UTF8.GetBytes(user.Id.ToString()),
            Name = user.Email,
            DisplayName = user.Email
        },
        <span class="hljs-keyword">new</span> List&lt;PublicKeyCredentialDescriptor&gt;(),
        AuthenticatorSelection.Default,
        AttestationConveyancePreference.None
    );

    HttpContext.Session.SetString(<span class="hljs-string">"fido2.attestationChallenge"</span>, options.Challenge);

    <span class="hljs-keyword">return</span> Ok(options);
}
</code></pre>
<p>Notice:</p>
<ul>
<li><p>Challenge is stored server-side.</p>
</li>
<li><p>Attestation preference set to <code>None</code> (privacy-friendly).</p>
</li>
<li><p>No credentials excluded in this example.</p>
</li>
</ul>
<h2 id="heading-step-2-verify-attestation">Step 2: Verify Attestation</h2>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpPost(<span class="hljs-meta-string">"verify-registration"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">VerifyRegistration</span>(<span class="hljs-params">[FromBody] AuthenticatorAttestationRawResponse attestation</span>)</span>
{
    <span class="hljs-keyword">var</span> challenge = HttpContext.Session.GetString(<span class="hljs-string">"fido2.attestationChallenge"</span>);

    <span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> _fido2.MakeNewCredentialAsync(
        attestation,
        <span class="hljs-keyword">new</span> List&lt;PublicKeyCredentialDescriptor&gt;(),
        (args) =&gt; args.Challenge == challenge
    );

    <span class="hljs-keyword">var</span> credential = <span class="hljs-keyword">new</span> WebAuthnCredential
    {
        UserId = GetCurrentUserId(),
        CredentialId = result.Result.CredentialId,
        PublicKey = result.Result.PublicKey,
        SignatureCounter = result.Result.Counter
    };

    _db.WebAuthnCredentials.Add(credential);
    <span class="hljs-keyword">await</span> _db.SaveChangesAsync();

    <span class="hljs-keyword">return</span> Ok();
}
</code></pre>
<p>Key insight:</p>
<p><mark>The challenge validator delegate must explicitly check equality.</mark></p>
<p>Do not assume the library does that for you.</p>
<hr />
<h1 id="heading-authentication-flow-assertion">Authentication Flow (Assertion)</h1>
<h2 id="heading-generate-assertion-options">Generate Assertion Options</h2>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> options = _fido2.GetAssertionOptions(
    storedCredentials,
    UserVerificationRequirement.Preferred
);

HttpContext.Session.SetString(<span class="hljs-string">"fido2.challenge"</span>, options.Challenge);
</code></pre>
<h2 id="heading-verify-assertion">Verify Assertion</h2>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> _fido2.MakeAssertionAsync(
    clientResponse,
    storedCredential.PublicKey,
    storedCredential.SignatureCounter,
    args =&gt; args.Challenge == challenge
);

storedCredential.SignatureCounter = result.Counter;
<span class="hljs-keyword">await</span> _db.SaveChangesAsync();
</code></pre>
<p>The counter update is not optional.</p>
<p>It is part of replay protection.</p>
<hr />
<h1 id="heading-common-implementation-pitfalls">Common Implementation Pitfalls</h1>
<h2 id="heading-1-base64url-encoding-mismatches">1. Base64URL encoding mismatches</h2>
<p>Browser returns ArrayBuffers.<br />ASP.NET expects byte arrays.</p>
<p>If encoding conversion is inconsistent, verification fails silently.</p>
<p>Solution: Use consistent Base64URL encoding utilities.</p>
<h3 id="heading-example">Example</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> assertion = <span class="hljs-keyword">await</span> navigator.credentials.get({ <span class="hljs-attr">publicKey</span>: options });

<span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"/api/auth/verify-webauthn"</span>, {
  <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
  <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify(assertion)
});
</code></pre>
<p>Problem: <code>assertion.rawId</code> is an ArrayBuffer — not Base64URL.</p>
<p>Explicit conversion helpers:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">bufferToBase64Url</span>(<span class="hljs-params">buffer</span>) </span>{
  <span class="hljs-keyword">return</span> btoa(<span class="hljs-built_in">String</span>.fromCharCode(...new <span class="hljs-built_in">Uint8Array</span>(buffer)))
    .replace(<span class="hljs-regexp">/\+/g</span>, <span class="hljs-string">'-'</span>)
    .replace(<span class="hljs-regexp">/\//g</span>, <span class="hljs-string">'_'</span>)
    .replace(<span class="hljs-regexp">/=/g</span>, <span class="hljs-string">''</span>);
}
</code></pre>
<h2 id="heading-2-forgetting-challenge-persistence">2. Forgetting challenge persistence</h2>
<p>If the challenge:</p>
<ul>
<li><p>is not stored,</p>
</li>
<li><p>or stored per user incorrectly,</p>
</li>
<li><p>or overwritten in concurrent requests,</p>
</li>
</ul>
<p>verification fails.</p>
<p>Challenge must be:</p>
<ul>
<li><p>short-lived,</p>
</li>
<li><p>per session,</p>
</li>
<li><p>non-reusable.</p>
</li>
</ul>
<h3 id="heading-example-1">Example</h3>
<pre><code class="lang-csharp">HttpContext.Session.SetString(<span class="hljs-string">"challenge"</span>, options.Challenge);
</code></pre>
<p>Then later:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> challenge = HttpContext.Session.GetString(<span class="hljs-string">"fido2.challenge"</span>);
</code></pre>
<p>By using 2 different keys would introduce this bug:</p>
<pre><code class="lang-csharp">Fido2VerificationException: Challenge mismatch
</code></pre>
<p>Or:</p>
<pre><code class="lang-csharp">Fido2VerificationException: Invalid challenge.
</code></pre>
<h2 id="heading-3-not-validating-origin">3. Not validating origin</h2>
<p>Origin mismatch is a common deployment issue.</p>
<p>If your production URL differs from development configuration, authentication breaks.</p>
<h3 id="heading-example-2">Example:</h3>
<p>Your production:</p>
<pre><code class="lang-csharp">https:<span class="hljs-comment">//app.yourdomain.com</span>
</code></pre>
<p>But config says:</p>
<pre><code class="lang-csharp">Origin = <span class="hljs-string">"https://yourdomain.com"</span>
</code></pre>
<p>Subdomain mismatch would lead to this error:</p>
<pre><code class="lang-csharp">Fido2VerificationException: Invalid origin
</code></pre>
<p>Or:</p>
<pre><code class="lang-csharp">Origin https:<span class="hljs-comment">//app.yourdomain.com does not match expected https://yourdomain.com</span>
</code></pre>
<h2 id="heading-4-counter-mishandling">4. Counter mishandling</h2>
<p>Some authenticators:</p>
<ul>
<li><p>return 0 initially.</p>
</li>
<li><p>do not increment as expected.</p>
</li>
</ul>
<p>Your logic must handle legitimate zero counters.</p>
<p>Rejecting zero blindly causes user lockout.</p>
<h3 id="heading-example-3">Example</h3>
<p>Authenticator returns:</p>
<pre><code class="lang-javascript">counter = <span class="hljs-number">0</span>
</code></pre>
<p>Stored counter also:</p>
<pre><code class="lang-sql">0
</code></pre>
<p>Your logic:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">if</span> (result.Counter &lt;= storedCounter)
{
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> SecurityException(<span class="hljs-string">"Possible cloned authenticator"</span>);
}
</code></pre>
<p>Immediate lockout and return error:</p>
<pre><code class="lang-csharp">Fido2VerificationException: Signature counter did not increase.
</code></pre>
<p>Or your own thrown exception:</p>
<pre><code class="lang-csharp">Possible cloned authenticator detected.
</code></pre>
<p>Correct logic: Only enforce monotonicity when counter &gt; 0.</p>
<h2 id="heading-5-misunderstanding-attestation">5. Misunderstanding attestation</h2>
<p>Attestation verifies device manufacturer.</p>
<p>Most applications do not need this.</p>
<p>Setting <code>AttestationConveyancePreference.None</code>:</p>
<ul>
<li><p>avoids privacy concerns,</p>
</li>
<li><p>reduces complexity,</p>
</li>
<li><p>avoids metadata verification headaches.</p>
</li>
</ul>
<h3 id="heading-example-4">Example:</h3>
<p>You enable:</p>
<pre><code class="lang-csharp">AttestationConveyancePreference.Direct
</code></pre>
<p>Now browser returns full attestation.</p>
<p>But you don’t validate metadata, which would returns:</p>
<pre><code class="lang-csharp">Fido2VerificationException: Attestation format not supported
</code></pre>
<p>Or:</p>
<pre><code class="lang-csharp">Fido2VerificationException: No metadata service configured
</code></pre>
<h2 id="heading-bonus-browser-side-errors">Bonus: Browser-Side Errors</h2>
<h3 id="heading-user-cancels">User Cancels</h3>
<pre><code class="lang-javascript">DOMException: The operation was aborted.
</code></pre>
<h3 id="heading-not-allowed">Not Allowed</h3>
<pre><code class="lang-javascript">DOMException: The user aborted a request.
</code></pre>
<h3 id="heading-unsupported-platform">Unsupported Platform</h3>
<pre><code class="lang-javascript">NotSupportedError: The operation is not supported.
</code></pre>
<p>These are not backend problems — but your UX must handle them gracefully.</p>
<hr />
<h1 id="heading-what-surprised-me-during-implementation">What Surprised Me During Implementation</h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771317391660/baa24c64-7482-4f3e-aea6-d4291af80a6b.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-1-how-much-state-management-matters">1. How much state management matters</h2>
<p>The cryptography is handled by the library.</p>
<p>The complexity lives in:</p>
<ul>
<li><p>challenge storage,</p>
</li>
<li><p>session lifecycle,</p>
</li>
<li><p>device registration state,</p>
</li>
<li><p>error branching.</p>
</li>
</ul>
<p>WebAuthn is less about math and more about disciplined state handling.</p>
<h2 id="heading-2-browser-inconsistencies">2. Browser inconsistencies</h2>
<p>Different browsers:</p>
<ul>
<li><p>format errors differently,</p>
</li>
<li><p>handle cancellation differently,</p>
</li>
<li><p>vary in UI timing.</p>
</li>
</ul>
<p>Your retry UX must account for that.</p>
<h2 id="heading-3-the-importance-of-fallback">3. The importance of fallback</h2>
<p>The first time a device:</p>
<ul>
<li><p>failed biometric recognition,</p>
</li>
<li><p>or returned unexpected counter values,</p>
</li>
</ul>
<p>I realized:</p>
<p>Passwordless-only systems are fragile.</p>
<p>Fallback is not optional.</p>
<h2 id="heading-4-offline-expectations-vs-reality">4. Offline expectations vs reality</h2>
<p>Because this is a PWA, users assume:</p>
<p>“It’s installed. It should just work.”</p>
<p>But WebAuthn requires:</p>
<ul>
<li><p>live challenge from server,</p>
</li>
<li><p>real-time verification.</p>
</li>
</ul>
<p>Offline login is not true authentication.</p>
<p>Designing expectations around that was essential.</p>
<h2 id="heading-5-the-psychological-difference">5. The psychological difference</h2>
<p>Once implemented properly:</p>
<p>Users stopped typing passwords.</p>
<p>They trusted the system more.</p>
<p>That was not because of UI polish.</p>
<p>It was because:</p>
<ul>
<li><p>no secrets were transmitted,</p>
</li>
<li><p>no reset emails were needed,</p>
</li>
<li><p>no password rules existed.</p>
</li>
</ul>
<p>Security felt natural.</p>
<p>That is rare.</p>
<hr />
<h1 id="heading-final-reflection">Final Reflection</h1>
<p>Implementing WebAuthn is not:</p>
<ul>
<li><p>copying code from documentation,</p>
</li>
<li><p>adding biometric login,</p>
</li>
<li><p>or flipping a feature flag.</p>
</li>
</ul>
<p>It is:</p>
<ul>
<li><p>modeling credentials correctly,</p>
</li>
<li><p>handling state carefully,</p>
</li>
<li><p>validating challenges strictly,</p>
</li>
<li><p>updating counters reliably,</p>
</li>
<li><p>integrating session management securely.</p>
</li>
</ul>
<p>It is architecture expressed through code.</p>
<p>In the next article, we’ll examine the integration of Feide OIDC in more depth — including account linking, token validation, and how federated identity interacts with my passwordless credential lifecycle.</p>
<p>Because WebAuthn proves possession.</p>
<p>Federation proves identity continuity.</p>
<p>Both are required for resilient systems.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p>→ <strong>Article 8 — Implementing WebAuthn in Practice</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Passwordless PWA Flow Architecture Walkthrough]]></title><description><![CDATA[Modern authentication diagrams are clean.
Real systems are not.
My architecture intentionally combines:

WebAuthn (FIDO2) for phishing-resistant authentication

Feide (OIDC) for federated identity, recovery, and bootstrap

SQL Server for credential p...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough</guid><category><![CDATA[fido2-net-lib]]></category><category><![CDATA[Feide]]></category><category><![CDATA[HTTP-only Cookies]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Vue.js]]></category><category><![CDATA[SQL Server]]></category><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[asp.net core]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Mon, 16 Feb 2026 11:05:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771239227385/5175a271-2ff0-49c4-a694-3542226658fd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Modern authentication diagrams are clean.</p>
<p>Real systems are not.</p>
<p>My architecture intentionally combines:</p>
<ul>
<li><p>WebAuthn (FIDO2) for phishing-resistant authentication</p>
</li>
<li><p>Feide (OIDC) for federated identity, recovery, and bootstrap</p>
</li>
<li><p>SQL Server for credential persistence</p>
</li>
<li><p>HTTP-only cookies for secure session handling</p>
</li>
<li><p>VueJS PWA as the user-facing layer</p>
</li>
</ul>
<p>At the center of the system is one key decision:</p>
<blockquote>
<p>Does this user already have passwordless enabled?</p>
</blockquote>
<p>Everything branches from there.</p>
<h3 id="heading-disclaimer">Disclaimer</h3>
<p><em>This article describes architectural patterns and technical approaches based on a real-world implementation. All examples, code snippets, and flow descriptions have been generalized and simplified for educational purposes. No proprietary business logic, confidential configurations, credentials, or organization-specific details are disclosed. The focus is strictly on publicly documented standards (WebAuthn, OIDC) and implementation patterns within a standard VueJS + ASP.NET Core + SQL Server stack.</em></p>
<hr />
<h1 id="heading-the-real-flowchart-the-system-as-a-decision-tree">The Real Flowchart: The System as a Decision Tree</h1>
<p>My initial flowchart expresses the core logic clearly:</p>
<ol>
<li><p>User requests authentication.</p>
</li>
<li><p>System checks: Has passwordless been enabled?</p>
</li>
<li><p>If yes → Attempt WebAuthn authentication.</p>
</li>
<li><p>If no → Redirect to Feide OIDC.</p>
</li>
<li><p>If WebAuthn fails → Allow retry.</p>
</li>
<li><p>If retries exhausted → End or fallback.</p>
</li>
<li><p>After successful OIDC → Offer passwordless registration.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771239027313/293700cd-6bb8-4230-99eb-8872aca5f56d.png" alt class="image--center mx-auto" /></p>
<p>This is not UX decoration.</p>
<p>This is an explicit trust state machine.</p>
<p>Let’s walk through it step by step with real code.</p>
<hr />
<h1 id="heading-step-1-vuejs-pwa-begin-authentication">Step 1 — VueJS PWA: Begin Authentication</h1>
<p>The PWA does not guess the strategy.</p>
<p>It asks the backend.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// VueJS (Composition API)</span>
<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">beginLogin</span>(<span class="hljs-params">email</span>) </span>{
  <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"/api/auth/begin"</span>, {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
    <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> },
    <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify({ email })
  });

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> response.json();

  <span class="hljs-keyword">if</span> (result.strategy === <span class="hljs-string">"webauthn"</span>) {
    <span class="hljs-keyword">await</span> startWebAuthn(result.options);
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (result.strategy === <span class="hljs-string">"oidc"</span>) {
    <span class="hljs-built_in">window</span>.location.href = result.redirectUrl;
  }
}
</code></pre>
<p>The browser is a mediator.<br />It does not decide trust.</p>
<hr />
<h1 id="heading-step-2-aspnet-core-decide-the-strategy">Step 2 — ASP.NET Core: Decide the Strategy</h1>
<p>My backend controls the trust graph.</p>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpPost(<span class="hljs-meta-string">"begin"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">Begin</span>(<span class="hljs-params">[FromBody] LoginRequest request</span>)</span>
{
    <span class="hljs-keyword">var</span> user = <span class="hljs-keyword">await</span> _db.Users
        .Include(u =&gt; u.Credentials)
        .FirstOrDefaultAsync(u =&gt; u.Email == request.Email);

    <span class="hljs-keyword">if</span> (user == <span class="hljs-literal">null</span> || !user.Credentials.Any())
    {
        <span class="hljs-keyword">return</span> Ok(<span class="hljs-keyword">new</span> {
            strategy = <span class="hljs-string">"oidc"</span>,
            redirectUrl = BuildFeideRedirect()
        });
    }

    <span class="hljs-keyword">var</span> fido2 = <span class="hljs-keyword">new</span> Fido2(<span class="hljs-keyword">new</span> Fido2Configuration
    {
        ServerDomain = <span class="hljs-string">"yourdomain.com"</span>,
        ServerName = <span class="hljs-string">"Your App"</span>,
        Origin = <span class="hljs-string">"https://yourdomain.com"</span>
    });

    <span class="hljs-keyword">var</span> options = fido2.GetAssertionOptions(
        user.Credentials.Select(c =&gt; <span class="hljs-keyword">new</span> PublicKeyCredentialDescriptor(c.CredentialId)).ToList(),
        UserVerificationRequirement.Preferred
    );

    HttpContext.Session.SetString(<span class="hljs-string">"fido2.challenge"</span>, options.Challenge);

    <span class="hljs-keyword">return</span> Ok(<span class="hljs-keyword">new</span> {
        strategy = <span class="hljs-string">"webauthn"</span>,
        options
    });
}
</code></pre>
<p>Why this branch exists:</p>
<ul>
<li><p>WebAuthn only works if credentials exist.</p>
</li>
<li><p>Backend must know account state.</p>
</li>
<li><p>Trust decisions cannot be client-side.</p>
</li>
</ul>
<hr />
<h1 id="heading-step-3-webauthn-authentication-vuejs-browser-api">Step 3 — WebAuthn Authentication (VueJS + Browser API)</h1>
<pre><code class="lang-javascript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">startWebAuthn</span>(<span class="hljs-params">options</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> assertion = <span class="hljs-keyword">await</span> navigator.credentials.get({
      <span class="hljs-attr">publicKey</span>: options
    });

    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"/api/auth/verify-webauthn"</span>, {
      <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
      <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> },
      <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify(assertion)
    });

    <span class="hljs-keyword">if</span> (res.ok) {
      <span class="hljs-built_in">window</span>.location.href = <span class="hljs-string">"/dashboard"</span>;
    } <span class="hljs-keyword">else</span> {
      showRetryOption();
    }
  } <span class="hljs-keyword">catch</span> (err) {
    showRetryOption();
  }
}
</code></pre>
<p>The browser enforces:</p>
<ul>
<li><p>Origin binding</p>
</li>
<li><p>Authenticator interaction</p>
</li>
<li><p>User presence / verification</p>
</li>
</ul>
<p>It does not verify the signature.</p>
<p>That’s backend responsibility.</p>
<hr />
<h1 id="heading-step-4-backend-verification-with-fido2-net-lib">Step 4 — Backend Verification with fido2-net-lib</h1>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpPost(<span class="hljs-meta-string">"verify-webauthn"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">Verify</span>(<span class="hljs-params">[FromBody] AuthenticatorAssertionRawResponse clientResponse</span>)</span>
{
    <span class="hljs-keyword">var</span> challenge = HttpContext.Session.GetString(<span class="hljs-string">"fido2.challenge"</span>);

    <span class="hljs-keyword">var</span> storedCredential = <span class="hljs-keyword">await</span> _db.Credentials
        .FirstOrDefaultAsync(c =&gt; c.CredentialId == clientResponse.Id);

    <span class="hljs-keyword">if</span> (storedCredential == <span class="hljs-literal">null</span>)
        <span class="hljs-keyword">return</span> Unauthorized();

    <span class="hljs-keyword">var</span> fido2 = <span class="hljs-keyword">new</span> Fido2(_config);

    <span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> fido2.MakeAssertionAsync(
        clientResponse,
        storedCredential.PublicKey,
        storedCredential.SignatureCounter,
        args =&gt; args.Challenge == challenge
    );

    storedCredential.SignatureCounter = result.Counter;
    <span class="hljs-keyword">await</span> _db.SaveChangesAsync();

    SignInUser(storedCredential.UserId);

    <span class="hljs-keyword">return</span> Ok();
}
</code></pre>
<p>This branch exists because:</p>
<ul>
<li><p>Only the server verifies cryptographic proof.</p>
</li>
<li><p>Counters detect cloned authenticators.</p>
</li>
<li><p>Session issuance must be server-controlled.</p>
</li>
</ul>
<hr />
<h1 id="heading-session-handling-http-only-cookie">Session Handling — HTTP-Only Cookie</h1>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title">SignInUser</span>(<span class="hljs-params">Guid userId</span>)</span>
{
    <span class="hljs-keyword">var</span> claims = <span class="hljs-keyword">new</span> List&lt;Claim&gt;
    {
        <span class="hljs-keyword">new</span> Claim(ClaimTypes.NameIdentifier, userId.ToString())
    };

    <span class="hljs-keyword">var</span> identity = <span class="hljs-keyword">new</span> ClaimsIdentity(claims, <span class="hljs-string">"Cookies"</span>);
    <span class="hljs-keyword">var</span> principal = <span class="hljs-keyword">new</span> ClaimsPrincipal(identity);

    HttpContext.SignInAsync(<span class="hljs-string">"Cookies"</span>, principal, <span class="hljs-keyword">new</span> AuthenticationProperties
    {
        IsPersistent = <span class="hljs-literal">true</span>,
        ExpiresUtc = DateTime.UtcNow.AddHours(<span class="hljs-number">8</span>)
    });
}
</code></pre>
<p>Configured in <code>Startup.cs</code>:</p>
<pre><code class="lang-csharp">services.AddAuthentication(<span class="hljs-string">"Cookies"</span>)
    .AddCookie(<span class="hljs-string">"Cookies"</span>, options =&gt;
    {
        options.Cookie.HttpOnly = <span class="hljs-literal">true</span>;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });
</code></pre>
<p>Why HTTP-only cookie?</p>
<ul>
<li><p>Protects against XSS token theft.</p>
</li>
<li><p>Avoids storing JWT in localStorage.</p>
</li>
<li><p>Keeps session server-controlled.</p>
</li>
</ul>
<hr />
<h1 id="heading-oidc-fallback-feide-integration">OIDC Fallback — Feide Integration</h1>
<p>If passwordless is not enabled, redirect to Feide.</p>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">string</span> <span class="hljs-title">BuildFeideRedirect</span>(<span class="hljs-params"></span>)</span>
{
    <span class="hljs-keyword">return</span> <span class="hljs-string">$"<span class="hljs-subst">{_config[<span class="hljs-string">"Feide:Authority"</span>]}</span>/authorize"</span> +
           <span class="hljs-string">$"?response_type=code"</span> +
           <span class="hljs-string">$"&amp;client_id=<span class="hljs-subst">{_config[<span class="hljs-string">"Feide:ClientId"</span>]}</span>"</span> +
           <span class="hljs-string">$"&amp;redirect_uri=<span class="hljs-subst">{_config[<span class="hljs-string">"Feide:RedirectUri"</span>]}</span>"</span> +
           <span class="hljs-string">$"&amp;scope=openid profile email"</span> +
           <span class="hljs-string">$"&amp;code_challenge=<span class="hljs-subst">{GeneratePKCEChallenge()}</span>"</span> +
           <span class="hljs-string">$"&amp;code_challenge_method=S256"</span>;
}
</code></pre>
<p>Callback endpoint:</p>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpGet(<span class="hljs-meta-string">"callback"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">Callback</span>(<span class="hljs-params"><span class="hljs-keyword">string</span> code</span>)</span>
{
    <span class="hljs-keyword">var</span> token = <span class="hljs-keyword">await</span> ExchangeCodeForToken(code);

    <span class="hljs-keyword">var</span> idToken = ValidateIdToken(token.IdToken);

    <span class="hljs-keyword">var</span> user = <span class="hljs-keyword">await</span> FindOrCreateUser(idToken.Sub);

    SignInUser(user.Id);

    <span class="hljs-keyword">if</span> (!user.Credentials.Any())
        <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/enable-passwordless"</span>);

    <span class="hljs-keyword">return</span> Redirect(<span class="hljs-string">"/dashboard"</span>);
}
</code></pre>
<p>This branch exists because:</p>
<ul>
<li><p>Devices are lost.</p>
</li>
<li><p>Users switch devices.</p>
</li>
<li><p>Federation provides lifecycle continuity.</p>
</li>
<li><p>OIDC provides bootstrap trust.</p>
</li>
</ul>
<hr />
<h1 id="heading-webauthn-registration-after-oidc">WebAuthn Registration After OIDC</h1>
<p>When enabling passwordless:</p>
<pre><code class="lang-csharp">[<span class="hljs-meta">HttpPost(<span class="hljs-meta-string">"register-options"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> IActionResult <span class="hljs-title">RegisterOptions</span>(<span class="hljs-params"></span>)</span>
{
    <span class="hljs-keyword">var</span> user = GetCurrentUser();

    <span class="hljs-keyword">var</span> options = _fido2.RequestNewCredential(
        <span class="hljs-keyword">new</span> Fido2User
        {
            DisplayName = user.Email,
            Id = Encoding.UTF8.GetBytes(user.Id.ToString()),
            Name = user.Email
        },
        <span class="hljs-keyword">new</span> List&lt;PublicKeyCredentialDescriptor&gt;(),
        AuthenticatorSelection.Default,
        AttestationConveyancePreference.None
    );

    HttpContext.Session.SetString(<span class="hljs-string">"fido2.attestationChallenge"</span>, options.Challenge);

    <span class="hljs-keyword">return</span> Ok(options);
}
</code></pre>
<p>Client registers via <code>navigator.credentials.create</code>.</p>
<p>Server verifies and stores:</p>
<pre><code class="lang-csharp">_db.Credentials.Add(<span class="hljs-keyword">new</span> Credential {
    UserId = user.Id,
    CredentialId = result.Result.CredentialId,
    PublicKey = result.Result.PublicKey,
    SignatureCounter = result.Result.Counter
});
</code></pre>
<p>Why this branch exists:</p>
<ul>
<li><p>Passwordless-first upgrades users.</p>
</li>
<li><p>Registration is explicit.</p>
</li>
<li><p>Device lifecycle is managed.</p>
</li>
</ul>
<hr />
<h1 id="heading-role-breakdown">Role Breakdown</h1>
<h3 id="heading-browser-vuejs-pwa">Browser (VueJS PWA)</h3>
<ul>
<li><p>Initiates flows</p>
</li>
<li><p>Calls WebAuthn API</p>
</li>
<li><p>Handles redirects</p>
</li>
<li><p>Does not store session tokens</p>
</li>
</ul>
<h3 id="heading-authenticator">Authenticator</h3>
<ul>
<li><p>Stores private key</p>
</li>
<li><p>Verifies biometric locally</p>
</li>
<li><p>Signs challenges</p>
</li>
<li><p>Never exposes key</p>
</li>
</ul>
<h3 id="heading-backend-net-core">Backend (.NET Core)</h3>
<ul>
<li><p>Controls strategy</p>
</li>
<li><p>Generates challenges</p>
</li>
<li><p>Verifies assertions</p>
</li>
<li><p>Tracks counters</p>
</li>
<li><p>Integrates OIDC</p>
</li>
<li><p>Issues session cookie</p>
</li>
<li><p>Persists credentials in SQL Server</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771239572210/e9813a80-1065-4d01-af47-fe0abd3be62b.png" alt class="image--center mx-auto" /></p>
<p>Trust is centralized.<br />Proof is decentralized.</p>
<hr />
<h1 id="heading-why-each-branch-exists">Why Each Branch Exists</h1>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Branch</strong></td><td><strong>Real-World Reason</strong></td></tr>
</thead>
<tbody>
<tr>
<td>WebAuthn first</td><td>Phishing-resistant primary auth</td></tr>
<tr>
<td>OIDC fallback</td><td>Recovery + cross-device bootstrap</td></tr>
<tr>
<td>Retry WebAuthn</td><td>Biometric glitches happen</td></tr>
<tr>
<td>Registration after OIDC</td><td>Upgrade path to passwordless</td></tr>
<tr>
<td>HTTP-only session cookie</td><td>Protect against XSS token theft</td></tr>
<tr>
<td>Counter tracking</td><td>Detect cloned authenticators</td></tr>
</tbody>
</table>
</div><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771239807725/61c028d2-2704-4583-beab-c5dd8f636999.png" alt class="image--center mx-auto" /></p>
<p>None of these branches are decorative.</p>
<p>Each corresponds to a failure mode in reality.</p>
<hr />
<h1 id="heading-final-architectural-insight">Final Architectural Insight</h1>
<p>This system is not:</p>
<p>“Biometric login.”</p>
<p>It is:</p>
<ul>
<li><p>Identity bootstrap via federation.</p>
</li>
<li><p>Device-bound authentication via FIDO2.</p>
</li>
<li><p>Session integrity via secure cookies.</p>
</li>
<li><p>Lifecycle management via SQL persistence.</p>
</li>
<li><p>Explicit failure handling.</p>
</li>
<li><p>Clear decision tree.</p>
</li>
</ul>
<p>Passwordless-first is not about removing complexity.</p>
<p>It is about relocating trust:</p>
<ul>
<li><p>Away from shared secrets.</p>
</li>
<li><p>Toward cryptographic proof.</p>
</li>
<li><p>While preserving federated continuity.</p>
</li>
</ul>
<p>And when drawn as a flowchart, the system looks clean.</p>
<p>When implemented in VueJS + ASP.NET Core + SQL Server + Feide + fido2-net-lib, it becomes real.</p>
<p>And real systems are where architecture proves itself.</p>
<p>Next article, we’ll explore what broke, what surprised us, and what we learned when this passwordless-first architecture moved from diagram to production.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p>→ <strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[UX and Failure Are Part of the Security Model]]></title><description><![CDATA[Security engineers love cryptography because it is clean.
Humans are not.
The strongest authentication protocol in the world can be undone by:

a confusing error message,

an unclear retry flow,

a missing recovery path,

or a user who simply wants t...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model</guid><category><![CDATA[Authentication UX]]></category><category><![CDATA[Identity Recovery]]></category><category><![CDATA[Multi-Device Authentication]]></category><category><![CDATA[Security Design]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Application Security]]></category><category><![CDATA[user experience]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 15 Feb 2026 03:09:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771122764029/eb2b4cfc-7db5-47c7-89d4-a6ddddc5d1d7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Security engineers love cryptography because it is clean.</p>
<p>Humans are not.</p>
<p>The strongest authentication protocol in the world can be undone by:</p>
<ul>
<li><p>a confusing error message,</p>
</li>
<li><p>an unclear retry flow,</p>
</li>
<li><p>a missing recovery path,</p>
</li>
<li><p>or a user who simply wants to get their work done.</p>
</li>
</ul>
<p>If your authentication design does not account for failure as a first-class scenario, it is not secure — it is brittle.</p>
<p>Passwordless systems amplify this truth.</p>
<p>When WebAuthn works, it feels effortless.<br />When it fails, it reveals whether your architecture was designed for real life or for a demo.</p>
<p>UX is not decoration layered on top of security.<br />UX is how security expresses itself.</p>
<hr />
<h2 id="heading-retry-flows-are-part-of-the-threat-model">Retry flows are part of the threat model</h2>
<p>Consider a simple scenario:</p>
<p>A user attempts WebAuthn authentication.<br />They cancel the prompt.</p>
<p>What does that mean?</p>
<ul>
<li><p>They changed their mind?</p>
</li>
<li><p>The biometric failed?</p>
</li>
<li><p>The authenticator was unavailable?</p>
</li>
<li><p>A malicious script attempted background authentication?</p>
</li>
<li><p>They clicked too quickly?</p>
</li>
</ul>
<p>Your system must interpret failure deliberately.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771123104756/638acb36-c76c-4640-adb7-fdc3846265dd.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-immediate-retry">Immediate retry?</h3>
<p>Too many automatic retries can:</p>
<ul>
<li><p>confuse users,</p>
</li>
<li><p>create loops,</p>
</li>
<li><p>mask real issues.</p>
</li>
</ul>
<h3 id="heading-manual-retry">Manual retry?</h3>
<p>Clear, explicit retry buttons give users control — and reduce panic.</p>
<h3 id="heading-escalation-to-fallback">Escalation to fallback?</h3>
<p>At what point does the system say:<br />“Let’s use your identity provider instead”?</p>
<p>Retry logic is not UX polish.<br />It is part of the attack surface.</p>
<p>An attacker probing authentication flows will:</p>
<ul>
<li><p>trigger errors,</p>
</li>
<li><p>observe timing differences,</p>
</li>
<li><p>test fallback conditions.</p>
</li>
</ul>
<p>Your retry model must:</p>
<ul>
<li><p>avoid leaking information,</p>
</li>
<li><p>avoid enabling brute force,</p>
</li>
<li><p>avoid trapping legitimate users.</p>
</li>
</ul>
<hr />
<h2 id="heading-lockouts-protection-or-punishment">Lockouts: protection or punishment?</h2>
<p>Lockouts are traditionally used to prevent brute-force attacks.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771123509538/d6960af8-f555-473b-b8a3-9146b7c44c65.png" alt class="image--center mx-auto" /></p>
<p>But in passwordless systems:</p>
<ul>
<li><p>there is no password to brute force,</p>
</li>
<li><p>biometric verification happens locally,</p>
</li>
<li><p>challenge–response is resistant to replay.</p>
</li>
</ul>
<p>So what are we locking out?</p>
<p>If:</p>
<ul>
<li><p>a signature counter mismatch occurs,</p>
</li>
<li><p>an authenticator appears cloned,</p>
</li>
<li><p>repeated failures happen,</p>
</li>
</ul>
<p>a lockout might be justified.</p>
<p>But lockouts must be:</p>
<ul>
<li><p>transparent,</p>
</li>
<li><p>recoverable,</p>
</li>
<li><p>tied to real risk signals.</p>
</li>
</ul>
<p>Otherwise, they punish legitimate users for:</p>
<ul>
<li><p>device glitches,</p>
</li>
<li><p>browser inconsistencies,</p>
</li>
<li><p>OS updates,</p>
</li>
<li><p>or simply aging hardware.</p>
</li>
</ul>
<p>A mature system distinguishes between:</p>
<ul>
<li><p>suspicious activity,</p>
</li>
<li><p>normal friction.</p>
</li>
</ul>
<p>Graceful degradation is more secure than aggressive rejection.</p>
<hr />
<h2 id="heading-multi-device-reality">Multi-device reality</h2>
<p>Real users do not live on a single device.</p>
<p>They:</p>
<ul>
<li><p>switch between phone and laptop,</p>
</li>
<li><p>replace hardware every few years,</p>
</li>
<li><p>clear browsers,</p>
</li>
<li><p>use shared or managed devices.</p>
</li>
</ul>
<p>A passwordless-first system must assume:</p>
<ul>
<li><p>multiple credentials per account,</p>
</li>
<li><p>multiple authenticators per user,</p>
</li>
<li><p>credentials that appear and disappear over time.</p>
</li>
</ul>
<p>This changes UX expectations.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771123889379/f8167111-cb72-4d84-85a4-9ff7a95f98af.png" alt class="image--center mx-auto" /></p>
<p>When a user logs in from a new device, the system should:</p>
<ul>
<li><p>not imply something is wrong,</p>
</li>
<li><p>guide them through identity verification,</p>
</li>
<li><p>allow secure credential registration.</p>
</li>
</ul>
<p>Multi-device support is not optional.<br />It is the default human condition.</p>
<hr />
<h2 id="heading-lost-device-scenarios-are-inevitable">Lost device scenarios are inevitable</h2>
<p>The most dangerous authentication system is one that assumes users will never lose access to their authenticators.</p>
<p>Phones are lost.<br />Laptops are stolen.<br />Security keys are misplaced.</p>
<p>If your system has no structured recovery path, users will demand one — and you will implement it under pressure.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771124119965/33e5d747-ba46-4e76-9781-2b5413af97e1.png" alt class="image--center mx-auto" /></p>
<p>Good recovery design includes:</p>
<ol>
<li><p>A trusted bootstrap identity method (e.g., OIDC).</p>
</li>
<li><p>Clear verification steps.</p>
</li>
<li><p>Revocation of lost credentials.</p>
</li>
<li><p>Controlled registration of new credentials.</p>
</li>
<li><p>Audit visibility for the user.</p>
</li>
</ol>
<p>Recovery must be:</p>
<ul>
<li><p>secure,</p>
</li>
<li><p>observable,</p>
</li>
<li><p>friction-aware.</p>
</li>
</ul>
<p>Security questions are not recovery.<br />Email-only resets are not recovery.<br />Administrative override is not recovery.</p>
<p>Federated identity exists partly to solve this lifecycle problem.</p>
<hr />
<h2 id="heading-why-fallback-is-not-a-weakness">Why fallback is not a weakness</h2>
<p>There is a persistent misconception:</p>
<p>“If the system falls back to another method, it weakens security.”</p>
<p>This is only true if fallback is poorly designed.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771124312723/51066a65-5fd3-4546-a9d8-55bb19a5f5f5.png" alt class="image--center mx-auto" /></p>
<p>Fallback becomes dangerous when it:</p>
<ul>
<li><p>bypasses primary controls,</p>
</li>
<li><p>uses weaker authentication without policy,</p>
</li>
<li><p>exists only as an emergency hack.</p>
</li>
</ul>
<p>Fallback becomes strong when it:</p>
<ul>
<li><p>is part of the architecture,</p>
</li>
<li><p>requires equivalent assurance,</p>
</li>
<li><p>is auditable,</p>
</li>
<li><p>is rate-limited,</p>
</li>
<li><p>and does not undermine the trust model.</p>
</li>
</ul>
<p>In passwordless-first systems:</p>
<p>WebAuthn provides:</p>
<ul>
<li>phishing-resistant, device-bound authentication.</li>
</ul>
<p>OIDC provides:</p>
<ul>
<li><p>identity portability,</p>
</li>
<li><p>lifecycle continuity,</p>
</li>
<li><p>bootstrap trust.</p>
</li>
</ul>
<p>They are not substitutes.<br />They are complementary trust anchors.</p>
<p>The presence of fallback does not weaken security.<br />Unplanned fallback does.</p>
<hr />
<h2 id="heading-graceful-degradation-is-a-security-feature">Graceful degradation is a security feature</h2>
<p>Graceful degradation means:</p>
<p>If the optimal path fails,<br />the system degrades to a slightly less optimal but still secure path —<br />without chaos.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771124592093/21fb5c5f-c290-4812-9181-85a21691ba48.png" alt class="image--center mx-auto" /></p>
<p>For example:</p>
<ul>
<li><p>WebAuthn unavailable → redirect to OIDC.</p>
</li>
<li><p>OIDC temporarily down → delay login with clear messaging.</p>
</li>
<li><p>Authenticator counter mismatch → require identity re-verification.</p>
</li>
</ul>
<p>The goal is not uninterrupted access at any cost.<br />The goal is continuity of trust.</p>
<p>Users interpret friction differently depending on clarity.</p>
<p>An unexplained failure feels insecure.<br />A clearly communicated alternative feels safe.</p>
<hr />
<h2 id="heading-ux-decisions-shape-security-outcomes">UX decisions shape security outcomes</h2>
<p>A confusing biometric prompt can cause:</p>
<ul>
<li><p>users to disable security features,</p>
</li>
<li><p>users to choose weaker alternatives,</p>
</li>
<li><p>users to distrust the system.</p>
</li>
</ul>
<p>An unclear fallback path can cause:</p>
<ul>
<li><p>support overload,</p>
</li>
<li><p>ad hoc account resets,</p>
</li>
<li><p>insecure manual overrides.</p>
</li>
</ul>
<p>Every prompt, error message, and redirect is part of the security boundary.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771124896501/ae624f28-4e74-4459-ba71-6890648b14ad.png" alt class="image--center mx-auto" /></p>
<p>When designing authentication UX, ask:</p>
<ul>
<li><p>Does this flow reduce ambiguity?</p>
</li>
<li><p>Does this error explain next steps?</p>
</li>
<li><p>Does this retry loop prevent confusion?</p>
</li>
<li><p>Does this fallback preserve assurance?</p>
</li>
</ul>
<p>Security is not just cryptographic strength.<br />It is user confidence combined with protocol integrity.</p>
<hr />
<h2 id="heading-designing-for-failure-makes-systems-stronger">Designing for failure makes systems stronger</h2>
<p>Authentication is not about proving success.<br />It is about handling failure safely.</p>
<p>Passwordless-first systems that ignore failure scenarios:</p>
<ul>
<li><p>look elegant in diagrams,</p>
</li>
<li><p>collapse under edge cases,</p>
</li>
<li><p>generate emergency workarounds.</p>
</li>
</ul>
<p>Passwordless-first systems that embrace failure:</p>
<ul>
<li><p>define fallback clearly,</p>
</li>
<li><p>support multi-device reality,</p>
</li>
<li><p>structure recovery intentionally,</p>
</li>
<li><p>treat UX as part of the threat model.</p>
</li>
</ul>
<p>That is the difference between a feature and an architecture.</p>
<p>In the next phase of this series, we move from theory to a real implementation — walking through a complete PWA authentication flow that combines WebAuthn and OpenID Connect in production.</p>
<p>Because architecture only proves itself when it survives the unpredictable behavior of actual users.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p>→ <strong>Article 6 — UX and Failure Are Part of the Security Model</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Designing a Passwordless-First PWA Architecture]]></title><description><![CDATA[By this point in the series, we’ve established three things:

Passwords are structurally fragile.

WebAuthn provides phishing-resistant, device-bound authentication.

OpenID Connect provides portable, federated identity.


Now comes the harder questi...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture</guid><category><![CDATA[Passwordless Architecture]]></category><category><![CDATA[Authentication Design]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[OIDC]]></category><category><![CDATA[session management]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[pkce]]></category><category><![CDATA[Application Architecture]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sat, 14 Feb 2026 10:35:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770993591401/d8499dbf-f1ed-4b1e-8977-57a1e9221e5f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>By this point in the series, we’ve established three things:</p>
<ul>
<li><p>Passwords are structurally fragile.</p>
</li>
<li><p>WebAuthn provides phishing-resistant, device-bound authentication.</p>
</li>
<li><p>OpenID Connect provides portable, federated identity.</p>
</li>
</ul>
<p>Now comes the harder question:</p>
<p>How do you design a real Progressive Web App where all of this coexists cleanly?</p>
<p>Because authentication in a PWA is not just about verifying a user once.<br />It’s about handling devices, sessions, fallbacks, offline behavior, and long-lived installs — without quietly reintroducing the weaknesses you just eliminated.</p>
<p>A passwordless-first architecture is not “always use WebAuthn.”<br />It’s about deciding when to use it, when to fall back, and how to make those decisions explicit.</p>
<hr />
<h2 id="heading-decision-points-when-to-attempt-webauthn-vs-fallback">Decision points: when to attempt WebAuthn vs fallback</h2>
<p>A mature system does not guess. It decides.</p>
<p>There are several common entry scenarios:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771062058030/1beeb680-fead-46a7-b929-d6ad8e669919.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-1-known-returning-user-with-registered-credentials">1. Known returning user with registered credentials</h3>
<p>If the server knows:</p>
<ul>
<li><p>this identity has WebAuthn credentials registered,</p>
</li>
<li><p>and the browser supports WebAuthn,</p>
</li>
</ul>
<p>then the system should attempt WebAuthn immediately.</p>
<p>This is the fast path:</p>
<ul>
<li><p>request challenge,</p>
</li>
<li><p>call <code>navigator.credentials.get()</code>,</p>
</li>
<li><p>verify assertion,</p>
</li>
<li><p>issue session.</p>
</li>
</ul>
<p>This path should feel frictionless.</p>
<h3 id="heading-2-user-has-no-registered-credential">2. User has no registered credential</h3>
<p>If the server sees:</p>
<ul>
<li>no WebAuthn credential on file,</li>
</ul>
<p>it must not attempt WebAuthn.</p>
<p>Instead:</p>
<ul>
<li><p>redirect to federated login (OIDC),</p>
</li>
<li><p>or use whatever bootstrap identity method exists.</p>
</li>
</ul>
<p>After successful identity verification:</p>
<ul>
<li>offer credential registration.</li>
</ul>
<p>Passwordless-first does not mean passwordless-only.</p>
<h3 id="heading-3-webauthn-attempt-fails">3. WebAuthn attempt fails</h3>
<p>Failure can mean:</p>
<ul>
<li><p>user cancels,</p>
</li>
<li><p>authenticator unavailable,</p>
</li>
<li><p>browser does not support feature,</p>
</li>
<li><p>device lost,</p>
</li>
<li><p>counter mismatch,</p>
</li>
<li><p>challenge expired.</p>
</li>
</ul>
<p>Your architecture must define what failure means.</p>
<p>Some failures allow retry.<br />Some require fallback to OIDC.</p>
<p>The critical point:<br />Fallback is not an afterthought. It is a planned branch.</p>
<p>If your system has no defined fallback path, it is not production-ready.</p>
<hr />
<h2 id="heading-server-responsibilities-the-part-nobody-can-skip">Server responsibilities: the part nobody can skip</h2>
<p>Passwordless pushes complexity into correctness.</p>
<p>Your server is responsible for:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771062454217/a7b1bd4c-ba78-43c9-8ad5-e04fa34718ea.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-credential-storage">Credential storage</h3>
<p>For each credential, you must store:</p>
<ul>
<li><p>credential ID,</p>
</li>
<li><p>public key,</p>
</li>
<li><p>signature counter,</p>
</li>
<li><p>user association,</p>
</li>
<li><p>metadata (optional).</p>
</li>
</ul>
<p>This storage must be:</p>
<ul>
<li><p>integrity-protected,</p>
</li>
<li><p>scoped per user,</p>
</li>
<li><p>revocable.</p>
</li>
</ul>
<p>Storing only the public key is not enough.<br />You must track counters to detect cloned authenticators.</p>
<h3 id="heading-challenge-management">Challenge management</h3>
<p>Every authentication attempt must include:</p>
<ul>
<li><p>a cryptographically random challenge,</p>
</li>
<li><p>short expiration,</p>
</li>
<li><p>binding to user and session state.</p>
</li>
</ul>
<p>Challenges must:</p>
<ul>
<li><p>be unpredictable,</p>
</li>
<li><p>not reusable,</p>
</li>
<li><p>not long-lived.</p>
</li>
</ul>
<p>If you reuse challenges, you reintroduce replay risk.</p>
<h3 id="heading-assertion-verification">Assertion verification</h3>
<p>Server must:</p>
<ul>
<li><p>validate signature against stored public key,</p>
</li>
<li><p>confirm challenge matches,</p>
</li>
<li><p>check origin and RP ID,</p>
</li>
<li><p>verify counter monotonicity.</p>
</li>
</ul>
<p>This is where many implementations quietly break.</p>
<p>WebAuthn security is only as strong as the verification logic.</p>
<h3 id="heading-credential-lifecycle-management">Credential lifecycle management</h3>
<p>Real systems need:</p>
<ul>
<li><p>credential revocation,</p>
</li>
<li><p>device labeling,</p>
</li>
<li><p>multi-device support,</p>
</li>
<li><p>audit logs.</p>
</li>
</ul>
<p>Without lifecycle management, passwordless becomes brittle.</p>
<hr />
<h2 id="heading-session-management-in-pwas">Session management in PWAs</h2>
<p>Here is where things become interesting.</p>
<p>PWAs blur the line between:</p>
<ul>
<li><p>website,</p>
</li>
<li><p>installed app,</p>
</li>
<li><p>long-running client.</p>
</li>
</ul>
<p>Session management must balance:</p>
<ul>
<li><p>security,</p>
</li>
<li><p>persistence,</p>
</li>
<li><p>user convenience.</p>
</li>
</ul>
<p>After WebAuthn or OIDC authentication:</p>
<ul>
<li>you still need a session mechanism.</li>
</ul>
<p>Common options:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771062685370/7edbcffe-ebf2-4a0c-8eb1-f46afe5be007.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-server-side-session-recommended">Server-side session (recommended)</h3>
<ul>
<li><p>Store session ID in HTTP-only cookie.</p>
</li>
<li><p>Session data lives on server.</p>
</li>
<li><p>Token not accessible to JavaScript.</p>
</li>
</ul>
<p>This minimizes XSS risk.</p>
<h3 id="heading-stateless-jwt-stored-in-cookie">Stateless JWT stored in cookie</h3>
<ul>
<li><p>Signed JWT issued after authentication.</p>
</li>
<li><p>Stored in secure, HTTP-only cookie.</p>
</li>
<li><p>Verified per request.</p>
</li>
</ul>
<p>Useful for distributed systems, but must:</p>
<ul>
<li><p>be short-lived,</p>
</li>
<li><p>rotated carefully.</p>
</li>
</ul>
<h3 id="heading-local-storage-generally-unsafe">Local storage (generally unsafe)</h3>
<p>Storing tokens in localStorage:</p>
<ul>
<li><p>exposes them to XSS,</p>
</li>
<li><p>encourages long-lived tokens,</p>
</li>
<li><p>complicates revocation.</p>
</li>
</ul>
<p>For PWAs especially, local storage can feel convenient.<br />It is usually the wrong tradeoff.</p>
<hr />
<h2 id="heading-secure-token-handling-cookies-vs-storage">Secure token handling: cookies vs storage</h2>
<p>The rule is simple:</p>
<p><mark>If JavaScript can read it, malicious JavaScript can steal it.</mark></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771062893642/33011612-cdcb-48f6-83bb-cdd810a24bb3.png" alt class="image--center mx-auto" /></p>
<p>HTTP-only cookies:</p>
<ul>
<li><p>are not accessible via JS,</p>
</li>
<li><p>are automatically sent with requests,</p>
</li>
<li><p>support SameSite protections,</p>
</li>
<li><p>reduce XSS impact.</p>
</li>
</ul>
<p>When properly configured:</p>
<ul>
<li><p>Secure,</p>
</li>
<li><p>HTTP-only,</p>
</li>
<li><p>SameSite=Lax or Strict,</p>
</li>
</ul>
<p><mark>cookies are safer for session tokens than browser storage.</mark></p>
<p>The complexity lies in:</p>
<ul>
<li><p>CSRF protection,</p>
</li>
<li><p>cross-origin flows,</p>
</li>
<li><p>OIDC redirect handling.</p>
</li>
</ul>
<p>These must be addressed deliberately — not avoided.</p>
<hr />
<h2 id="heading-why-offline-pwas-complicate-authentication">Why offline PWAs complicate authentication</h2>
<p>PWAs can:</p>
<ul>
<li><p>cache assets,</p>
</li>
<li><p>run offline,</p>
</li>
<li><p>queue background sync,</p>
</li>
<li><p>appear installed and persistent.</p>
</li>
</ul>
<p>Authentication systems were not originally designed for this.</p>
<p>Here’s the tension:</p>
<p>WebAuthn requires:</p>
<ul>
<li><p>server-issued challenge,</p>
</li>
<li><p>live verification,</p>
</li>
<li><p>session establishment.</p>
</li>
</ul>
<p>Offline mode has:</p>
<ul>
<li><p>no server access,</p>
</li>
<li><p>no challenge generation,</p>
</li>
<li><p>no verification endpoint.</p>
</li>
</ul>
<p>Therefore:</p>
<p><mark>You cannot perform real authentication offline.</mark></p>
<p>What you can do is:</p>
<ul>
<li><p>cache a previously authenticated session,</p>
</li>
<li><p>gate access behind local checks,</p>
</li>
<li><p>defer sensitive actions until online.</p>
</li>
</ul>
<p>This creates design decisions:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771064892450/27a5d5a2-4830-4fb7-ad8a-1def458520c7.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-how-long-can-an-offline-session-persist">How long can an offline session persist?</h3>
<p>Too short:</p>
<ul>
<li>poor UX.</li>
</ul>
<p>Too long:</p>
<ul>
<li>increased risk on stolen devices.</li>
</ul>
<h3 id="heading-what-actions-are-allowed-offline">What actions are allowed offline?</h3>
<p>Read-only?<br />Cached data only?<br />Queued writes?</p>
<p>Offline capability forces you to define trust boundaries explicitly.</p>
<hr />
<h2 id="heading-designing-the-authentication-state-machine">Designing the authentication state machine</h2>
<p>A passwordless-first PWA architecture behaves like a state machine:</p>
<ol>
<li><p>Unknown user → bootstrap via OIDC.</p>
</li>
<li><p>Known user with credential → attempt WebAuthn.</p>
</li>
<li><p>WebAuthn success → issue session.</p>
</li>
<li><p>WebAuthn failure → retry or fallback.</p>
</li>
<li><p>Credential lost → recover via OIDC.</p>
</li>
<li><p>Offline mode → limited local session access.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771065250525/df57fb58-bbfb-46ad-b1a1-45f45f463c24.png" alt class="image--center mx-auto" /></p>
<p>Every branch must be:</p>
<ul>
<li><p>intentional,</p>
</li>
<li><p>tested,</p>
</li>
<li><p>observable.</p>
</li>
</ul>
<p>If your authentication system is not drawn as a flowchart, you probably haven’t finished designing it.</p>
<hr />
<h2 id="heading-passwordless-first-means-opinionated-design">Passwordless-first means opinionated design</h2>
<p>It means:</p>
<ul>
<li><p>WebAuthn is default.</p>
</li>
<li><p>Federation is structured fallback.</p>
</li>
<li><p>Sessions are server-controlled.</p>
</li>
<li><p>Tokens are protected from JavaScript.</p>
</li>
<li><p>Recovery is first-class.</p>
</li>
<li><p>Offline mode is constrained deliberately.</p>
</li>
</ul>
<p>It does not mean:</p>
<ul>
<li><p>removing identity providers,</p>
</li>
<li><p>removing server state,</p>
</li>
<li><p>trusting devices blindly,</p>
</li>
<li><p>or assuming biometrics solve lifecycle problems.</p>
</li>
</ul>
<p>Architecture is about deciding where trust lives.</p>
<p>Passwordless-first architectures shift trust:</p>
<ul>
<li><p>away from shared secrets,</p>
</li>
<li><p>toward device-bound credentials,</p>
</li>
<li><p>while preserving federated identity for continuity.</p>
</li>
</ul>
<p>In the next article, we’ll explore how UX decisions — error messages, prompts, retries — shape security outcomes more than cryptography alone.</p>
<p>Because even the strongest architecture must survive human behavior.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p>→ <strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[OpenID Connect as the Glue]]></title><description><![CDATA[If WebAuthn answers the question:

“Can this device prove control of a credential right now?”

OpenID Connect answers a different question entirely:

“Who is this person across systems, time, and organizations?”

Modern authentication systems fail wh...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue</guid><category><![CDATA[OpenID Connect]]></category><category><![CDATA[OIDC]]></category><category><![CDATA[Federated Identity]]></category><category><![CDATA[Authorization Code Flow]]></category><category><![CDATA[pkce]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Identity Architecture]]></category><category><![CDATA[Web Security]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Thu, 12 Feb 2026 15:22:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770820703641/6a05fe05-ac02-4de6-9d7e-64786373456f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If WebAuthn answers the question:</p>
<blockquote>
<p>“Can this device prove control of a credential right now?”</p>
</blockquote>
<p>OpenID Connect answers a different question entirely:</p>
<blockquote>
<p>“Who is this person across systems, time, and organizations?”</p>
</blockquote>
<p>Modern authentication systems fail when they confuse those two layers.</p>
<p>WebAuthn is about <strong>proof of possession</strong>.<br />OpenID Connect (OIDC) is about <strong>portable identity assertions</strong>.</p>
<p>When building a Progressive Web App, especially one meant to survive device loss, multi-device usage, and organizational boundaries, OIDC becomes the connective tissue that passwordless authentication alone cannot provide.</p>
<p>This article explains what OIDC actually does, why PWAs still need federated identity, and how flows like Authorization Code + PKCE fit into a passwordless-first architecture.</p>
<hr />
<h2 id="heading-what-openid-connect-actually-provides">What OpenID Connect actually provides</h2>
<p><mark>OpenID Connect is an identity layer built on OAuth 2.0.</mark></p>
<p>OAuth by itself is about <em>delegated authorization</em> — letting an app access resources on your behalf.</p>
<p>OIDC extends OAuth to answer:</p>
<blockquote>
<p>“Who is the authenticated user?”</p>
</blockquote>
<p>It does this by issuing an <strong>ID token</strong>, typically a signed JSON Web Token (JWT), containing claims such as:</p>
<ul>
<li><p>subject identifier,</p>
</li>
<li><p>issuer,</p>
</li>
<li><p>audience,</p>
</li>
<li><p>authentication time,</p>
</li>
<li><p>optional profile attributes.</p>
</li>
</ul>
<p>In plain terms, OIDC allows one trusted system (an Identity Provider, or IdP) to tell another system:</p>
<blockquote>
<p>“I have authenticated this user, and here is cryptographic proof.”</p>
</blockquote>
<p>That’s it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770907391672/7a2f0320-031a-49de-a91b-6e7933fd20c1.png" alt class="image--center mx-auto" /></p>
<p>OIDC does not:</p>
<ul>
<li><p>define how users authenticate internally,</p>
</li>
<li><p>guarantee phishing resistance,</p>
</li>
<li><p>manage device credentials,</p>
</li>
<li><p>handle authorization inside your app.</p>
</li>
</ul>
<p><mark>It provides </mark> <strong><mark>identity assertions</mark></strong><mark>, not session logic and not passwordless mechanics.</mark></p>
<p>This distinction is critical.</p>
<hr />
<h2 id="heading-what-oidc-does-not-provide">What OIDC does not provide</h2>
<p>Because OIDC is often treated as a complete solution, it’s worth being explicit about what it does not do.</p>
<p>It does not:</p>
<ul>
<li><p>eliminate passwords (many IdPs still use them internally),</p>
</li>
<li><p>replace device-bound authentication,</p>
</li>
<li><p>prevent phishing unless combined with phishing-resistant mechanisms,</p>
</li>
<li><p>manage account lifecycle inside your application.</p>
</li>
</ul>
<p><mark>OIDC is transport for identity claims.<br />It is not an authentication method in itself.</mark></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770907597223/8f36d01d-2164-4148-9720-2d9fc7754979.png" alt class="image--center mx-auto" /></p>
<p>Think of it as a passport issued by another authority.<br />It tells you who someone is — it does not determine how they proved it.</p>
<hr />
<h2 id="heading-why-pwas-still-need-federated-identity">Why PWAs still need federated identity</h2>
<p>At first glance, a passwordless PWA using WebAuthn might seem self-contained.</p>
<p>User registers a credential.<br />User signs in with a biometric.<br />Done.</p>
<p>But real systems rarely live in isolation.</p>
<p>PWAs face several realities:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770907793518/bd7dbef2-86fa-4788-8313-594c03198fd8.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-device-loss">Device loss</h3>
<p>If a user loses their only registered device, how do they recover?</p>
<p>WebAuthn is intentionally device-bound. That is a strength for security — but it requires a recovery path.</p>
<p>Federated identity allows:</p>
<ul>
<li><p>bootstrap access,</p>
</li>
<li><p>credential re-registration,</p>
</li>
<li><p>account restoration without weak recovery questions.</p>
</li>
</ul>
<h3 id="heading-multi-device-usage">Multi-device usage</h3>
<p>Users expect to log in from:</p>
<ul>
<li><p>phone,</p>
</li>
<li><p>laptop,</p>
</li>
<li><p>tablet,</p>
</li>
<li><p>shared workstation.</p>
</li>
</ul>
<p>WebAuthn supports multiple credentials per account — but how does the user prove ownership of the account to register a new device?</p>
<p>Federated identity provides a portable proof of identity across devices.</p>
<h3 id="heading-organizational-trust">Organizational trust</h3>
<p>In enterprise or education environments, users already have identities managed by:</p>
<ul>
<li><p>corporate directories,</p>
</li>
<li><p>institutional identity providers,</p>
</li>
<li><p>centralized account governance.</p>
</li>
</ul>
<p>Federated identity allows your PWA to integrate into that trust fabric instead of inventing its own.</p>
<h3 id="heading-lifecycle-management">Lifecycle management</h3>
<p>Identity providers often handle:</p>
<ul>
<li><p>account provisioning,</p>
</li>
<li><p>deactivation,</p>
</li>
<li><p>role updates,</p>
</li>
<li><p>compliance requirements.</p>
</li>
</ul>
<p>A passwordless-only system must reimplement these concerns or delegate them.</p>
<p>In most real-world architectures, delegation wins.</p>
<hr />
<h2 id="heading-authorization-code-pkce-conceptual-overview">Authorization Code + PKCE (conceptual overview)</h2>
<p>Modern browser-based applications — including PWAs — should use the <strong>Authorization Code flow with PKCE</strong> when integrating with OIDC.</p>
<p>You do not need to memorize the spec to understand the reasoning.</p>
<p>The flow exists to solve a simple problem:</p>
<blockquote>
<p>How can a public client (like a browser app) securely obtain tokens without exposing secrets?</p>
</blockquote>
<p>Here’s the high-level sequence:</p>
<ol>
<li><p>The app initiates login with the IdP.</p>
</li>
<li><p>The browser redirects the user to the IdP’s authentication page.</p>
</li>
<li><p>The user authenticates there.</p>
</li>
<li><p>The IdP redirects back with an authorization code.</p>
</li>
<li><p>The app exchanges that code for tokens.</p>
</li>
</ol>
<p>PKCE (Proof Key for Code Exchange) adds protection by:</p>
<ul>
<li><p>generating a one-time verifier,</p>
</li>
<li><p>sending a hashed version during the initial request,</p>
</li>
<li><p>proving possession of the original verifier during token exchange.</p>
</li>
</ul>
<p>This prevents intercepted authorization codes from being reused.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770908321799/bfe23842-644a-4a0a-a20b-127172c8c51e.png" alt class="image--center mx-auto" /></p>
<p>The key idea is this:<br /><mark>Authorization Code + PKCE prevents token theft in public clients.</mark></p>
<p>It does not replace WebAuthn.<br />It complements it.</p>
<hr />
<h2 id="heading-using-idps-for-bootstrap">Using IdPs for bootstrap</h2>
<p>One of the most elegant uses of OIDC in a passwordless-first architecture is during initial account creation.</p>
<p>A user may:</p>
<ul>
<li><p>authenticate via an external IdP,</p>
</li>
<li><p>receive a verified identity,</p>
</li>
<li><p>then register a WebAuthn credential tied to that account.</p>
</li>
</ul>
<p>After that:</p>
<ul>
<li><p>WebAuthn becomes the default authentication method,</p>
</li>
<li><p>OIDC remains available as fallback.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770909202936/accc826f-be48-4e30-aa79-6ee0c76ad313.png" alt class="image--center mx-auto" /></p>
<p>In this model:</p>
<ul>
<li><p>OIDC establishes identity,</p>
</li>
<li><p>WebAuthn establishes device-bound proof,</p>
</li>
<li><p>the two reinforce each other.</p>
</li>
</ul>
<hr />
<h2 id="heading-using-idps-for-recovery">Using IdPs for recovery</h2>
<p>Recovery is where pure passwordless systems often reveal their fragility.</p>
<p>If:</p>
<ul>
<li><p>the only credential is lost,</p>
</li>
<li><p>and no alternative exists,</p>
</li>
<li><p>the account becomes inaccessible.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770909337893/ab1d83ae-1675-411c-94a5-28a2fa6a7e08.png" alt class="image--center mx-auto" /></p>
<p>An IdP provides a recovery path that does not require:</p>
<ul>
<li><p>weak security questions,</p>
</li>
<li><p>email-only resets,</p>
</li>
<li><p>or administrative overrides.</p>
</li>
</ul>
<p>The system can:</p>
<ol>
<li><p>Require OIDC login.</p>
</li>
<li><p>Validate identity via trusted external authority.</p>
</li>
<li><p>Allow new credential registration.</p>
</li>
<li><p>Invalidate old credentials.</p>
</li>
</ol>
<p>This turns recovery into a structured process instead of an improvised exception.</p>
<hr />
<h2 id="heading-using-idps-for-portability">Using IdPs for portability</h2>
<p>Federated identity also enables portability across systems.</p>
<p>If multiple applications trust the same IdP:</p>
<ul>
<li><p>users authenticate once,</p>
</li>
<li><p>identity claims propagate,</p>
</li>
<li><p>account linking becomes consistent.</p>
</li>
</ul>
<p>WebAuthn credentials are bound to origins.<br />OIDC identities are portable across origins.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770909503536/f1fb8ed6-86a9-4600-929b-c8dea848628b.png" alt class="image--center mx-auto" /></p>
<p>The combination gives you:</p>
<ul>
<li><p>local phishing-resistant authentication,</p>
</li>
<li><p>global identity continuity.</p>
</li>
</ul>
<p>That pairing is powerful.</p>
<hr />
<h2 id="heading-webauthn-and-oidc-are-not-competitors">WebAuthn and OIDC are not competitors</h2>
<p>There is a persistent misconception that passwordless authentication replaces federated identity.</p>
<p>It doesn’t.</p>
<p>They solve orthogonal problems:</p>
<p>WebAuthn:</p>
<ul>
<li><p>secure, phishing-resistant proof of possession,</p>
</li>
<li><p>bound to origin,</p>
</li>
<li><p>device-specific.</p>
</li>
</ul>
<p>OIDC:</p>
<ul>
<li><p>portable identity assertion,</p>
</li>
<li><p>cross-system trust,</p>
</li>
<li><p>organizational integration.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770909649937/9ffdce4e-4663-44db-bda9-4423dcf3af74.png" alt class="image--center mx-auto" /></p>
<p>When combined thoughtfully:</p>
<ul>
<li><p>WebAuthn becomes the fast path,</p>
</li>
<li><p>OIDC becomes the resilient path,</p>
</li>
<li><p>the user experience remains smooth,</p>
</li>
<li><p>the architecture remains robust.</p>
</li>
</ul>
<hr />
<h2 id="heading-oidc-as-architectural-glue">OIDC as architectural glue</h2>
<p><mark>If WebAuthn is the lock on the door, OIDC is the passport system.</mark></p>
<p>WebAuthn ensures:</p>
<ul>
<li>the right key opens the right lock.</li>
</ul>
<p>OIDC ensures:</p>
<ul>
<li>the person behind the key is recognized across contexts.</li>
</ul>
<p>PWAs that attempt to eliminate federation entirely often rediscover its necessity the hard way — during device loss, enterprise integration, or compliance review.</p>
<p>The mature posture is not choosing between passwordless and federation.</p>
<p>It is designing a system where:</p>
<ul>
<li><p>passwordless handles everyday authentication,</p>
</li>
<li><p>federation handles identity continuity,</p>
</li>
<li><p>and neither is forced to solve the other’s problems.</p>
</li>
</ul>
<p>In the next article, we move from protocols to architecture:<br />how WebAuthn, OIDC, sessions, storage, and fallback flows combine inside a real PWA authentication system.</p>
<p>Because theory becomes meaningful only when it survives implementation.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p>→ <strong>Article 4 — OpenID Connect as the Glue</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[WebAuthn & FIDO2, Explained Without the Spec]]></title><description><![CDATA[If you read the WebAuthn specification end to end, you’ll come away with two thoughts:

This is extremely well designed.

No human should be expected to learn it this way.


WebAuthn didn’t appear to make logins prettier. It exists because the web ne...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec</guid><category><![CDATA[Authentication Architecture]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[passwordless authentication ]]></category><category><![CDATA[public-key cryptgraphy]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[biometrics]]></category><category><![CDATA[Application Security]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Tue, 10 Feb 2026 15:46:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770731509929/2052824b-999e-4981-b1fe-4b3c47f3c5e2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you read the WebAuthn specification end to end, you’ll come away with two thoughts:</p>
<ol>
<li><p>This is extremely well designed.</p>
</li>
<li><p>No human should be expected to learn it this way.</p>
</li>
</ol>
<p>WebAuthn didn’t appear to make logins prettier. It exists because the web needed a way to authenticate users <strong>without shared secrets</strong>, <strong>without training users to detect phishing</strong>, and <strong>without centralizing catastrophic risk</strong>.</p>
<p>This article explains what WebAuthn and FIDO2 solve, how they work at a conceptual level, and why their security properties emerge naturally from the design — not from UI tricks or user behavior.</p>
<p>No spec quotes. No diagrams full of acronyms. Just the moving parts that matter.</p>
<hr />
<h2 id="heading-the-problems-webauthn-was-designed-to-solve">The problems WebAuthn was designed to solve</h2>
<p>WebAuthn doesn’t fix “bad passwords.”<br />It eliminates the <em>need</em> for passwords entirely.</p>
<p>The core problems it targets are structural:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770731657554/c2a859e1-2097-4db1-9e61-694542a59e18.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-shared-secrets-dont-scale">Shared secrets don’t scale</h3>
<p>Passwords are secrets shared between user and server. That single fact creates:</p>
<ul>
<li><p>phishing,</p>
</li>
<li><p>credential stuffing,</p>
</li>
<li><p>password reuse,</p>
</li>
<li><p>database breach fallout.</p>
</li>
</ul>
<p>As long as the same secret can be typed and replayed, attackers will find ways to collect it.</p>
<h3 id="heading-servers-shouldnt-need-to-keep-login-secrets">Servers shouldn’t need to keep login secrets</h3>
<p>Even well-hashed passwords are still verifier secrets.<br />If an attacker steals the database, they gain:</p>
<ul>
<li><p>offline attack capability,</p>
</li>
<li><p>reusable material,</p>
</li>
<li><p>leverage across systems.</p>
</li>
</ul>
<p>WebAuthn removes this entire category of risk by design.</p>
<h3 id="heading-authentication-should-not-rely-on-user-judgment">Authentication should not rely on user judgment</h3>
<p>Security systems that depend on users spotting fake URLs are already lost.</p>
<p>WebAuthn pushes phishing resistance into the browser and protocol layer, where it belongs. Users don’t need to “be careful.” The system refuses to cooperate with the attacker.</p>
<hr />
<h2 id="heading-the-core-idea-public-key-credentials">The core idea: public-key credentials</h2>
<p>WebAuthn is built on public-key cryptography, but you don’t need to think in equations.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770733314294/9b8235cc-833e-40ef-8c1d-f274af420e41.png" alt class="image--center mx-auto" /></p>
<p>Here’s the mental model:</p>
<ul>
<li><p>Each user registers a <strong>credential</strong> for a specific website.</p>
</li>
<li><p>That credential is a <strong>key pair</strong>:</p>
<ul>
<li><p>a <strong>private key</strong> stored securely on the user’s device,</p>
</li>
<li><p>a <strong>public key</strong> stored on the server.</p>
</li>
</ul>
</li>
<li><p>The private key <strong>never leaves the device</strong>.</p>
</li>
<li><p>The public key is useless on its own.</p>
</li>
</ul>
<p>This immediately changes the trust model:</p>
<ul>
<li><p>stealing the server database doesn’t let attackers log in,</p>
</li>
<li><p>stealing one device doesn’t compromise other accounts,</p>
</li>
<li><p>credentials can’t be replayed or reused elsewhere.</p>
</li>
</ul>
<hr />
<h2 id="heading-challenges-and-assertions-proving-freshness">Challenges and assertions: proving freshness</h2>
<p>If the server never sees a secret, how does authentication work?</p>
<p>Through <strong>challenge–response</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770733636644/afe20df9-909a-4dfd-b725-03dbc940309a.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-the-challenge">The challenge</h3>
<p>When a user wants to authenticate:</p>
<ul>
<li><p>the server generates a random, unpredictable challenge,</p>
</li>
<li><p>the challenge is tied to the session and expires quickly,</p>
</li>
<li><p>the server sends it to the client.</p>
</li>
</ul>
<p>This ensures freshness.<br />An old response will never work again.</p>
<h3 id="heading-the-assertion">The assertion</h3>
<p>The client asks the authenticator:</p>
<ul>
<li>“Sign this challenge for this site.”</li>
</ul>
<p>The authenticator:</p>
<ul>
<li><p>verifies the user locally (if required),</p>
</li>
<li><p>signs the challenge with the private key,</p>
</li>
<li><p>returns a signed <strong>assertion</strong>.</p>
</li>
</ul>
<p>The server:</p>
<ul>
<li><p>verifies the signature using the stored public key,</p>
</li>
<li><p>confirms the challenge matches,</p>
</li>
<li><p>checks counters to detect replay or cloning.</p>
</li>
</ul>
<p>No secrets compared.<br />No passwords transmitted.<br />No reusable proof created.</p>
<hr />
<h2 id="heading-platform-vs-roaming-authenticators">Platform vs roaming authenticators</h2>
<p>Authenticators come in different forms, and WebAuthn treats them explicitly.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770734078594/3b686595-56f7-4c60-ba2e-94030076c2ef.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-platform-authenticators">Platform authenticators</h3>
<p>These are built into devices:</p>
<ul>
<li><p>phone biometrics,</p>
</li>
<li><p>laptop fingerprint readers,</p>
</li>
<li><p>OS-level secure enclaves.</p>
</li>
</ul>
<p>Characteristics:</p>
<ul>
<li><p>excellent UX,</p>
</li>
<li><p>tightly integrated with the device,</p>
</li>
<li><p>private keys stored in hardware-backed storage.</p>
</li>
</ul>
<p>They are ideal for PWAs because:</p>
<ul>
<li><p>they feel native,</p>
</li>
<li><p>they require no extra hardware,</p>
</li>
<li><p>they encourage passwordless adoption.</p>
</li>
</ul>
<h3 id="heading-roaming-authenticators">Roaming authenticators</h3>
<p>These are external devices:</p>
<ul>
<li><p>USB security keys,</p>
</li>
<li><p>NFC or Bluetooth tokens.</p>
</li>
</ul>
<p>Characteristics:</p>
<ul>
<li><p>portable across devices,</p>
</li>
<li><p>extremely strong isolation,</p>
</li>
<li><p>ideal for high-assurance environments.</p>
</li>
</ul>
<p>They solve a different problem: portability without centralization.</p>
<p>Well-designed systems allow <strong>both</strong>, because users have different constraints.</p>
<hr />
<h2 id="heading-user-presence-vs-user-verification">User presence vs user verification</h2>
<p>This distinction is subtle and often misunderstood.</p>
<h3 id="heading-user-presence-up">User presence (UP)</h3>
<p>User presence means:</p>
<ul>
<li><p>the user performed a conscious action,</p>
</li>
<li><p>such as touching a button or tapping a key.</p>
</li>
</ul>
<p>It prevents silent, background authentication.</p>
<h3 id="heading-user-verification-uv">User verification (UV)</h3>
<p>User verification means:</p>
<ul>
<li><p>the authenticator verified <em>who</em> is using it,</p>
</li>
<li><p>via biometrics or a PIN.</p>
</li>
</ul>
<p>UV answers: <em>is this the legitimate user of this device?</em><br />UP answers: <em>did someone physically interact with the device?</em></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770737256212/d6533844-c965-4aea-ac31-4ecab832dbb8.png" alt class="image--center mx-auto" /></p>
<p>WebAuthn supports both:</p>
<ul>
<li><p>some flows require UV,</p>
</li>
<li><p>others accept UP only,</p>
</li>
<li><p>policy decides what’s acceptable.</p>
</li>
</ul>
<p>This flexibility allows systems to balance:</p>
<ul>
<li><p>security,</p>
</li>
<li><p>accessibility,</p>
</li>
<li><p>device capability.</p>
</li>
</ul>
<hr />
<h2 id="heading-why-webauthn-is-phishing-resistant-by-design">Why WebAuthn is phishing-resistant by design</h2>
<p>This is the most important property — and it’s not accidental.</p>
<p>WebAuthn credentials are <strong>bound to origin</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770737587402/3898f1a6-3995-4a60-9e92-07d86fa2001d.png" alt class="image--center mx-auto" /></p>
<p>That means:</p>
<ul>
<li><p>a credential created for <code>example.com</code></p>
</li>
<li><p>will not work for <code>examp1e.com</code> (notice the difference)</p>
</li>
<li><p>or inside an iframe on another site</p>
</li>
<li><p>or on a cloned login page.</p>
</li>
</ul>
<p>The browser enforces this binding.<br />The user never sees the decision.</p>
<p>A phishing site can:</p>
<ul>
<li><p>perfectly mimic your UI,</p>
</li>
<li><p>use the same text,</p>
</li>
<li><p>even embed your real site visually.</p>
</li>
</ul>
<p>But when it asks the browser to authenticate:</p>
<ul>
<li><p>no matching credential exists,</p>
</li>
<li><p>the authenticator refuses,</p>
</li>
<li><p>the attack fails silently.</p>
</li>
</ul>
<p>No warning dialogs.<br />No user training.<br />No race against social engineering.</p>
<p>This is what “security by design” looks like.</p>
<hr />
<h2 id="heading-what-webauthn-does-not-do">What WebAuthn does <em>not</em> do</h2>
<p>Clarity here prevents bad architecture later.</p>
<p>WebAuthn does <strong>not</strong>:</p>
<ul>
<li><p>manage identities across systems,</p>
</li>
<li><p>recover lost accounts,</p>
</li>
<li><p>replace authorization logic,</p>
</li>
<li><p>eliminate the need for backend validation,</p>
</li>
<li><p>solve UX by itself.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770738078728/8bc09df4-2769-499e-b323-fb2cc6e9f8c6.png" alt class="image--center mx-auto" /></p>
<p>WebAuthn solves <strong>one problem extremely well</strong>:<br /><em>How can a user prove control of a credential securely, without shared secrets, and without being phishable?</em></p>
<p>Everything else still requires system design.</p>
<hr />
<h2 id="heading-webauthn-as-infrastructure-not-magic">WebAuthn as infrastructure, not magic</h2>
<p>When WebAuthn works well, it feels invisible:</p>
<ul>
<li><p>a prompt,</p>
</li>
<li><p>a touch,</p>
</li>
<li><p>and you’re in.</p>
</li>
</ul>
<p>That simplicity hides real complexity:</p>
<ul>
<li><p>cryptography,</p>
</li>
<li><p>device trust,</p>
</li>
<li><p>browser enforcement,</p>
</li>
<li><p>careful server validation.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770738240788/360af9ed-5cac-4783-a10a-07bcf23b9a57.png" alt class="image--center mx-auto" /></p>
<p>But that complexity exists whether you manage it or not.<br />WebAuthn simply exposes it in a form that can be reasoned about and secured.</p>
<p>In the next article, we’ll step back from the protocol and look at <strong>how WebAuthn fits into a full PWA architecture</strong> — including sessions, fallback paths, and real-world failure modes.</p>
<p>Because passwordless authentication doesn’t live in a vacuum.<br />It lives inside systems built by humans, for humans, on devices that get lost.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p>→ <strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[What “Passwordless” Actually Means]]></title><description><![CDATA[“Passwordless” has become one of those terms that everyone uses and few people define.
Depending on who you ask, it can mean:

logging in with Face ID,

receiving a magic link by email,

approving a push notification,

using a security key,

or never...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means</guid><category><![CDATA[Passwordless]]></category><category><![CDATA[authentication]]></category><category><![CDATA[Multi Factor Authentication]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[biometrics]]></category><category><![CDATA[Identity Architecture]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Application Security]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 08 Feb 2026 14:56:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770561027518/f04420b9-e618-4416-a1d1-5ea3d2096d81.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>“Passwordless” has become one of those terms that everyone uses and few people define.</p>
<p>Depending on who you ask, it can mean:</p>
<ul>
<li><p>logging in with Face ID,</p>
</li>
<li><p>receiving a magic link by email,</p>
</li>
<li><p>approving a push notification,</p>
</li>
<li><p>using a security key,</p>
</li>
<li><p>or never seeing a login screen at all.</p>
</li>
</ul>
<p>Some of these approaches are genuinely passwordless.<br />Some merely <em>hide</em> the password.<br />Some quietly depend on passwords more than ever.</p>
<p>If you don’t define what you mean by passwordless, you can’t design it — and you certainly can’t reason about its security.</p>
<p>This article draws clean boundaries between <strong>passwordless</strong>, <strong>MFA</strong>, and <strong>passwordless-first</strong>, explains the underlying authentication factors in plain language, and shows where WebAuthn actually fits in the picture.</p>
<hr />
<h2 id="heading-passwordless-vs-mfa-vs-passwordless-first">Passwordless vs MFA vs passwordless-first</h2>
<p>These terms are often used interchangeably. They are not the same.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770561393292/461aa2f7-4878-4e45-be4f-0e7dfcd33c03.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-mfa-strengthening-a-password-centric-system">MFA: strengthening a password-centric system</h3>
<p>Multi-Factor Authentication (MFA) starts from the assumption that a password exists.</p>
<p>The system asks for:</p>
<ul>
<li><p>something the user <em>knows</em> (password),</p>
</li>
<li><p>plus something they <em>have</em> (OTP, push approval),</p>
</li>
<li><p>or something they <em>are</em> (biometrics).</p>
</li>
</ul>
<p>MFA reduces risk, but the password remains:</p>
<ul>
<li><p>the primary identifier,</p>
</li>
<li><p>the primary target,</p>
</li>
<li><p>and the primary liability.</p>
</li>
</ul>
<p>If the password is phished, reused, or leaked, MFA becomes a race condition instead of a guarantee.</p>
<p>MFA is a reinforcement strategy — not a redesign.</p>
<h3 id="heading-passwordless-no-shared-secret-with-the-server">Passwordless: no shared secret with the server</h3>
<p>A system is truly passwordless when:</p>
<ul>
<li><p>the user does not know a reusable secret,</p>
</li>
<li><p>the server does not store a password equivalent,</p>
</li>
<li><p>and authentication relies on challenge–response, not comparison.</p>
</li>
</ul>
<p>This does <strong>not</strong> mean there is no authentication factor.<br />It means the factor is <strong>non-reusable</strong> and <strong>non-transferable</strong>.</p>
<p>Email magic links, for example, are passwordless — but fragile.<br />Security keys and WebAuthn credentials are passwordless — and strong.</p>
<p>The difference is not UX. It’s cryptography.</p>
<h3 id="heading-passwordless-first-an-architectural-posture">Passwordless-first: an architectural posture</h3>
<p>Passwordless-first describes <em>how the system is designed</em>, not a single mechanism.</p>
<p>In a passwordless-first system:</p>
<ul>
<li><p>passwordless is the default path,</p>
</li>
<li><p>fallback exists for recovery and portability,</p>
</li>
<li><p>and passwords (if they exist at all) are not the core identity proof.</p>
</li>
</ul>
<p>This distinction matters because:</p>
<ul>
<li><p>real users lose devices,</p>
</li>
<li><p>real systems need recovery,</p>
</li>
<li><p>real organizations need federation.</p>
</li>
</ul>
<p>Passwordless-first systems assume failure and design around it.<br />Pure passwordless systems often pretend failure won’t happen.</p>
<hr />
<h2 id="heading-the-three-authentication-factors-without-jargon">The three authentication factors (without jargon)</h2>
<p>Most authentication systems are built from three categories of evidence.<br />Understanding them clarifies almost every design decision.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770561711673/506526b9-1cb9-4fde-8862-a8e5e6cba3c1.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-knowledge-something-you-know">Knowledge: something you know</h3>
<p>Passwords, PINs, security questions.</p>
<p>Strengths:</p>
<ul>
<li><p>portable,</p>
</li>
<li><p>easy to reset.</p>
</li>
</ul>
<p>Weaknesses:</p>
<ul>
<li><p>phishable,</p>
</li>
<li><p>guessable,</p>
</li>
<li><p>reusable,</p>
</li>
<li><p>leakable.</p>
</li>
</ul>
<p>Knowledge factors scale badly because humans are terrible secret keepers.</p>
<h3 id="heading-possession-something-you-have">Possession: something you have</h3>
<p>Phones, hardware keys, authenticator apps, browsers with stored credentials.</p>
<p>Strengths:</p>
<ul>
<li><p>not easily copied,</p>
</li>
<li><p>can be bound to a device,</p>
</li>
<li><p>works well with cryptography.</p>
</li>
</ul>
<p>Weaknesses:</p>
<ul>
<li><p>devices can be lost,</p>
</li>
<li><p>possession must be proven securely.</p>
</li>
</ul>
<p>Possession factors are the backbone of modern passwordless systems.</p>
<h3 id="heading-inherence-something-you-are">Inherence: something you are</h3>
<p>Biometrics like fingerprints, face recognition, or voice.</p>
<p>Strengths:</p>
<ul>
<li><p>convenient,</p>
</li>
<li><p>fast,</p>
</li>
<li><p>user-friendly.</p>
</li>
</ul>
<p>Weaknesses:</p>
<ul>
<li><p>cannot be changed,</p>
</li>
<li><p>not secret,</p>
</li>
<li><p>not suitable for server-side verification.</p>
</li>
</ul>
<p>Biometrics are excellent <strong>local gates</strong>.<br />They are terrible <strong>remote identifiers</strong>.</p>
<p>This is why modern systems never send biometrics to servers. They use biometrics to unlock something else.</p>
<hr />
<h2 id="heading-where-webauthn-fits-and-what-it-actually-does">Where WebAuthn fits — and what it actually does</h2>
<p>WebAuthn does not authenticate users with biometrics.</p>
<p>WebAuthn authenticates <strong>devices and credentials</strong> using public-key cryptography.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770561992144/0f59fe7a-a17b-498c-b851-3844d921f91c.png" alt class="image--center mx-auto" /></p>
<p>Here’s the key idea:</p>
<ul>
<li><p>the server issues a random challenge,</p>
</li>
<li><p>the client signs it using a private key,</p>
</li>
<li><p>the server verifies the signature with a stored public key.</p>
</li>
</ul>
<p>That’s it.</p>
<p>Biometrics enter the picture only because:</p>
<ul>
<li><p>the private key is protected by the authenticator,</p>
</li>
<li><p>and the authenticator requires user verification (biometric or PIN) before using it.</p>
</li>
</ul>
<p>In other words:</p>
<ul>
<li><p>the biometric unlocks the key,</p>
</li>
<li><p>the key proves possession,</p>
</li>
<li><p>the signature proves freshness,</p>
</li>
<li><p>and the origin binding prevents phishing.</p>
</li>
</ul>
<p>WebAuthn combines:</p>
<ul>
<li><p>possession (the device),</p>
</li>
<li><p>optional inherence (biometric),</p>
</li>
<li><p>and strong cryptography.</p>
</li>
</ul>
<p>That combination is what makes it powerful — not the fingerprint itself.</p>
<hr />
<h2 id="heading-common-myths-about-passwordless">Common myths about passwordless</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770562443882/7c07baf9-8dde-426a-b7c7-aba0893e4b77.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-passwordless-means-no-backend">“Passwordless means no backend”</h3>
<p>False.</p>
<p>Passwordless systems require <strong>more backend discipline</strong>, not less.</p>
<p>The server must:</p>
<ul>
<li><p>generate and track challenges,</p>
</li>
<li><p>store credential metadata securely,</p>
</li>
<li><p>verify signatures correctly,</p>
</li>
<li><p>manage counters and replay protection,</p>
</li>
<li><p>and handle fallback and recovery.</p>
</li>
</ul>
<p>Passwordless removes one fragile secret.<br />It replaces it with protocol correctness.</p>
<h3 id="heading-passwordless-locks-users-to-one-device">“Passwordless locks users to one device”</h3>
<p>Only if you design it that way.</p>
<p>WebAuthn credentials are device-bound by default, but systems can support:</p>
<ul>
<li><p>multiple registered devices,</p>
</li>
<li><p>roaming authenticators,</p>
</li>
<li><p>cloud-synced credentials (with caveats),</p>
</li>
<li><p>federated recovery via identity providers.</p>
</li>
</ul>
<p>Device loss is not a WebAuthn problem.<br />It’s an identity lifecycle problem.</p>
<h3 id="heading-biometrics-identify-the-user">“Biometrics identify the user”</h3>
<p>They don’t.</p>
<p>Biometrics verify <em>local presence</em>.<br />They do not establish identity on the network.</p>
<p>Any system that treats biometrics as a remote identifier is misunderstanding both security and privacy.</p>
<h3 id="heading-passwordless-removes-the-need-for-identity-providers">“Passwordless removes the need for identity providers”</h3>
<p>It doesn’t.</p>
<p>Passwordless answers: <em>How does the user prove control right now?</em><br />Identity providers answer: <em>Who is this user across systems and time?</em></p>
<p>The strongest systems use both.</p>
<hr />
<h2 id="heading-passwordless-is-a-shift-in-trust-not-a-feature">Passwordless is a shift in trust, not a feature</h2>
<p>The real change passwordless introduces is <strong>where trust lives</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770562574148/c3500f3c-9b80-4c2c-a8c5-b45511883bad.png" alt class="image--center mx-auto" /></p>
<p>Passwords centralize trust on the server:</p>
<ul>
<li><p>one database,</p>
</li>
<li><p>many secrets,</p>
</li>
<li><p>catastrophic failure modes.</p>
</li>
</ul>
<p>Passwordless distributes trust:</p>
<ul>
<li><p>keys on devices,</p>
</li>
<li><p>verification on servers,</p>
</li>
<li><p>failure isolated per credential.</p>
</li>
</ul>
<p>This is why passwordless feels different when done properly.<br />It’s not just smoother — it’s structurally safer.</p>
<p>But only if it’s designed as a system.</p>
<p>In the next article, we’ll zoom in on <strong>WebAuthn and FIDO2 themselves</strong>, explaining how the protocol works without dragging you through the spec — and why it enables things passwords never could.</p>
<p>Because once you see the mechanics, the architectural choices become inevitable.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p>→ <strong>Article 2 — What “Passwordless” Actually Means</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Authentication Is Not Login]]></title><description><![CDATA[Modern web applications are full of login screens — but surprisingly few of them have a well-designed authentication system.
This distinction matters more than it sounds.If you treat authentication as a UI feature instead of a security system, every ...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login</guid><category><![CDATA[authentication]]></category><category><![CDATA[Identity Architecture]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[Passwordless]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[biometrics]]></category><category><![CDATA[threat modeling]]></category><category><![CDATA[Application Security]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sat, 07 Feb 2026 14:25:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770466363941/5a6dc25a-b406-4d6b-b4c4-98d6ccd4f059.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Modern web applications are full of login screens — but surprisingly few of them have a well-designed authentication system.</p>
<p>This distinction matters more than it sounds.<br />If you treat authentication as a UI feature instead of a security system, every decision that follows will be reactive, fragile, and hard to evolve. Passwordless authentication, biometrics, passkeys, and federated identity all fail when they are bolted onto the wrong mental model.</p>
<p>Before we talk about FIDO, WebAuthn, or PWAs, we need to untangle three ideas that are constantly conflated: <strong>identity</strong>, <strong>authentication</strong>, and <strong>authorization</strong>.</p>
<hr />
<h2 id="heading-identity-authentication-and-authorization-are-not-the-same-thing">Identity, authentication, and authorization are not the same thing</h2>
<p>They often appear together, but they solve different problems.</p>
<p><strong>Identity</strong> answers the question: <em>Who is this user supposed to be?</em><br />An identity is a logical construct. It might be an email address, a student ID, an employee number, or a subject identifier from an identity provider. Identity exists even when no one is logged in.</p>
<p><strong>Authentication</strong> answers the question: <em>Can this user prove they control that identity right now?</em><br />Authentication is an event. It happens at a moment in time, using evidence: something the user knows, has, or is. When authentication succeeds, the system gains confidence that the user is who they claim to be.</p>
<p><strong>Authorization</strong> answers the question: <em>What is this authenticated identity allowed to do?</em><br />Authorization is policy. It decides access to resources after authentication has already happened.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770471080567/b171c150-899b-4c5b-a03d-7f59bdb5f2ef.png" alt class="image--center mx-auto" /></p>
<p>A login screen collapses all three into a single gesture.<br />A well-designed system does not.</p>
<p>When people say “login,” they usually mean:</p>
<ul>
<li><p>identify the user,</p>
</li>
<li><p>authenticate them,</p>
</li>
<li><p>create a session,</p>
</li>
<li><p>and authorize access — all at once.</p>
</li>
</ul>
<p>This compression hides complexity, which is why authentication systems often break under real-world pressure.</p>
<hr />
<h2 id="heading-why-passwords-became-a-liability">Why passwords became a liability</h2>
<p>Passwords weren’t always terrible. They were simple, portable, and easy to implement. But they were never designed for the environment they now inhabit.</p>
<p>The modern web has:</p>
<ul>
<li><p>thousands of services per user,</p>
</li>
<li><p>phishing at industrial scale,</p>
</li>
<li><p>automated credential testing,</p>
</li>
<li><p>shared devices,</p>
</li>
<li><p>password managers,</p>
</li>
<li><p>and users trained to ignore security warnings just to get work done.</p>
</li>
</ul>
<p>Passwords fail not because users are careless, but because <strong>the model is brittle</strong>.</p>
<p>A password:</p>
<ul>
<li><p>must be remembered,</p>
</li>
<li><p>must be transmitted (even if hashed),</p>
</li>
<li><p>must be reused or rotated,</p>
</li>
<li><p>and must remain secret — forever.</p>
</li>
</ul>
<p>Every one of those constraints breaks under scale.</p>
<p>Once a password exists, it becomes:</p>
<ul>
<li><p>a reusable secret,</p>
</li>
<li><p>a target for phishing,</p>
</li>
<li><p>a commodity for attackers,</p>
</li>
<li><p>and a liability for operators.</p>
</li>
</ul>
<p>The security industry tried to patch this with:</p>
<ul>
<li><p>complexity rules,</p>
</li>
<li><p>forced rotation,</p>
</li>
<li><p>MFA bolted on afterward,</p>
</li>
<li><p>CAPTCHA,</p>
</li>
<li><p>and endless UX friction.</p>
</li>
</ul>
<p>The result was predictable: more prompts, more fatigue, more insecure workarounds.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770471954281/640c1d72-73ba-4f7c-85b1-89a8abd88ebf.png" alt class="image--center mx-auto" /></p>
<p>Passwordless authentication didn’t emerge because passwords were inconvenient.<br />It emerged because <strong>passwords are structurally incompatible with modern threat models</strong>.</p>
<hr />
<h2 id="heading-threat-models-that-actually-matter-for-pwas">Threat models that actually matter for PWAs</h2>
<p>Progressive Web Apps inherit all the threats of the web, plus a few of their own.</p>
<p>If you’re building a PWA, these are the threats that should shape your authentication design.</p>
<h3 id="heading-phishing">Phishing</h3>
<p>Phishing works because passwords are portable secrets.<br />A fake site only needs to look convincing long enough for the user to type something.</p>
<p>No amount of password complexity fixes this.<br />If the user can type the secret, an attacker can ask for it.</p>
<p>This is the single strongest argument for WebAuthn-based authentication: credentials are <strong>bound to origin</strong>. The browser refuses to authenticate for the wrong site. Phishing stops working at the protocol level, not the UX level.</p>
<h3 id="heading-credential-stuffing">Credential stuffing</h3>
<p>Attackers don’t guess passwords anymore. They replay them.</p>
<p>A breach in one system becomes an attack surface for thousands of others. PWAs are particularly exposed because they often serve global audiences with minimal friction to sign up.</p>
<p>Once a password database exists, credential stuffing is inevitable.</p>
<h3 id="heading-replay-attacks">Replay attacks</h3>
<p>If an authentication response can be reused, it will be.</p>
<p>Tokens, cookies, and session identifiers must be scoped, time-bound, and rotated correctly. PWAs complicate this because they blur the line between web app and installed app, often encouraging long-lived sessions.</p>
<p>Modern authentication systems rely on <strong>challenge–response</strong> instead of static secrets precisely to prevent replay.</p>
<h3 id="heading-client-compromise-and-shared-devices">Client compromise and shared devices</h3>
<p>PWAs run on devices you do not control:</p>
<ul>
<li><p>shared computers,</p>
</li>
<li><p>stolen phones,</p>
</li>
<li><p>locked-down corporate environments.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770473179328/f879d6e1-52ec-488e-a49b-6dab530c9647.png" alt class="image--center mx-auto" /></p>
<p>Authentication must assume that devices can be lost and recovered, not just trusted forever. This is where many “passwordless-only” designs quietly fail.</p>
<hr />
<h2 id="heading-why-just-add-biometrics-is-a-misunderstanding">Why “just add biometrics” is a misunderstanding</h2>
<p>Biometrics are not an authentication system.<br />They are a <strong>local user verification mechanism</strong>.</p>
<p>This distinction is subtle and critical.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770473996889/c1ca1b5e-1e1b-4820-82be-19ff99128621.png" alt class="image--center mx-auto" /></p>
<p>When a user authenticates with biometrics in a WebAuthn flow:</p>
<ul>
<li><p>the biometric never leaves the device,</p>
</li>
<li><p>it never identifies the user to the server,</p>
</li>
<li><p>and it is not the credential.</p>
</li>
</ul>
<p>The real credential is a <strong>cryptographic key pair</strong> stored in the authenticator.<br />The biometric merely unlocks the private key.</p>
<p>This means:</p>
<ul>
<li><p>biometrics do not replace identity,</p>
</li>
<li><p>biometrics do not replace account recovery,</p>
</li>
<li><p>biometrics do not replace authorization,</p>
</li>
<li><p>biometrics do not remove the need for backend logic.</p>
</li>
</ul>
<p>“Adding biometrics” without redesigning the authentication flow usually results in:</p>
<ul>
<li><p>biometric prompts guarding a password,</p>
</li>
<li><p>biometrics unlocking stored tokens,</p>
</li>
<li><p>or biometrics acting as cosmetic MFA.</p>
</li>
</ul>
<p>These designs feel modern but inherit all the weaknesses of the underlying system.</p>
<p>True passwordless authentication requires:</p>
<ul>
<li><p>server-issued challenges,</p>
</li>
<li><p>public-key verification,</p>
</li>
<li><p>device-bound credentials,</p>
</li>
<li><p>and a fallback strategy for when devices are lost or unavailable.</p>
</li>
</ul>
<p>Biometrics are part of the <em>experience</em>, not the <em>architecture</em>.</p>
<hr />
<h2 id="heading-authentication-is-a-system-not-a-screen">Authentication is a system, not a screen</h2>
<p>The core mistake teams make is treating authentication as a moment instead of a lifecycle.</p>
<p>A real authentication system must account for:</p>
<ul>
<li><p>enrollment,</p>
</li>
<li><p>authentication,</p>
</li>
<li><p>failure,</p>
</li>
<li><p>retry,</p>
</li>
<li><p>recovery,</p>
</li>
<li><p>device loss,</p>
</li>
<li><p>account linking,</p>
</li>
<li><p>and evolution over time.</p>
</li>
</ul>
<p>This is why modern systems combine approaches:</p>
<ul>
<li><p>passwordless for speed and phishing resistance,</p>
</li>
<li><p>federated identity for portability and recovery,</p>
</li>
<li><p>policy for authorization,</p>
</li>
<li><p>and UX as an explicit security control.</p>
</li>
</ul>
<p>The goal is not to eliminate login screens.<br />The goal is to design a system where <strong>authentication decisions are deliberate, layered, and resilient</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770474310473/74f0820d-cec8-4482-90f0-5faef33e00b2.png" alt class="image--center mx-auto" /></p>
<p>In the next article, we’ll narrow the scope and define what “passwordless” actually means — and what it does <em>not</em> mean — before diving into WebAuthn and FIDO2 themselves.</p>
<p>Because once the mental model is right, the APIs finally make sense.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas">Introduction</a></p>
</li>
<li><p>→ <strong>Article 1 — Authentication is Not Login</strong></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Introduction to Passwordless: Modern Authentication Patterns for PWAs]]></title><description><![CDATA[Passwords were never designed to scale with the modern web — and Progressive Web Apps inherit all of their weaknesses while adding new constraints of their own.
This series explores how modern PWAs can move beyond passwords using FIDO-powered biometr...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/introduction-to-passwordless-modern-authentication-patterns-for-pwas</guid><category><![CDATA[Identity Architecture]]></category><category><![CDATA[progressive web apps]]></category><category><![CDATA[authentication]]></category><category><![CDATA[Passwordless]]></category><category><![CDATA[#webauthn]]></category><category><![CDATA[#fido2]]></category><category><![CDATA[biometrics]]></category><category><![CDATA[OpenID Connect]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[FrontendArchitecture]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 01 Feb 2026 07:52:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769932110551/494a39f6-eb86-4ab2-baf9-31759ccbe8e7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Passwords were never designed to scale with the modern web — and Progressive Web Apps inherit all of their weaknesses while adding new constraints of their own.</p>
<p>This series explores how modern PWAs can move beyond passwords using <strong>FIDO-powered biometrics (WebAuthn)</strong>, without falling into the trap of treating passwordless as a silver bullet. Starting from foundational concepts and best practices, it builds toward a real-world implementation I’ve created and improved from a POC that combines <strong>passwordless-first authentication</strong> with <strong>OpenID Connect</strong> for fallback and recovery.</p>
<p>The goal is not to explain APIs in isolation, but to show how authentication systems are actually designed, operated, and evolved in practice.</p>
<hr />
<h2 id="heading-series-structure">Series structure</h2>
<h3 id="heading-part-i-foundations"><strong>Part I — Foundations</strong></h3>
<p>Goal: Align readers on <em>what authentication really is</em> before touching APIs.</p>
<p><strong>Article 1 — Authentication is Not Login</strong></p>
<ul>
<li><p>Identity vs authentication vs authorization</p>
</li>
<li><p>Why passwords became a liability</p>
</li>
<li><p>Threat models relevant to PWAs (phishing, replay, credential stuffing)</p>
</li>
<li><p>Why “just add biometrics” is a misunderstanding</p>
</li>
</ul>
<p><strong>Article 2 — What “Passwordless” Actually Means</strong></p>
<ul>
<li><p>Passwordless vs MFA vs passwordless-first</p>
</li>
<li><p>Knowledge, possession, inherence factors (plain-language explanation)</p>
</li>
<li><p>Where WebAuthn fits</p>
</li>
<li><p>Common myths (passwordless = no backend, passwordless = device lock-in)</p>
</li>
</ul>
<hr />
<h3 id="heading-part-ii-standards-amp-building-blocks"><strong>Part II — Standards &amp; building blocks</strong></h3>
<p>Goal: Introduce the standards without drowning readers in specs.</p>
<p><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></p>
<ul>
<li><p>What problems WebAuthn solves</p>
</li>
<li><p>Public-key credentials, challenges, assertions</p>
</li>
<li><p>Platform vs roaming authenticators</p>
</li>
<li><p>User verification vs user presence</p>
</li>
<li><p>Why WebAuthn is phishing-resistant by design</p>
</li>
</ul>
<p><strong>Article 4 — OpenID Connect as the Glue</strong></p>
<ul>
<li><p>What OIDC actually provides (and what it doesn’t)</p>
</li>
<li><p>Why PWAs still need federated identity</p>
</li>
<li><p>Authorization Code + PKCE (conceptual, not tutorial-heavy)</p>
</li>
<li><p>Using IdPs for bootstrap, recovery, and portability</p>
</li>
</ul>
<hr />
<h3 id="heading-part-iii-architecture-amp-best-practices"><strong>Part III — Architecture &amp; best practices</strong></h3>
<p>Goal: Show how real systems combine these pieces.</p>
<p><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></p>
<ul>
<li><p>Decision points: when to attempt WebAuthn vs fallback</p>
</li>
<li><p>Server responsibilities (credential storage, challenges, counters)</p>
</li>
<li><p>Session management in PWAs</p>
</li>
<li><p>Secure token handling (cookies vs storage)</p>
</li>
<li><p>Why offline PWAs complicate auth more than expected</p>
</li>
</ul>
<p><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></p>
<ul>
<li><p>Retry flows, lockouts, and graceful degradation</p>
</li>
<li><p>Multi-device reality</p>
</li>
<li><p>Lost device scenarios</p>
</li>
<li><p>Why fallback is not a weakness but a requirement</p>
</li>
</ul>
<hr />
<h3 id="heading-part-iv-my-real-implementation"><strong>Part IV — My real implementation</strong></h3>
<p>Goal: Ground theory in a real, working flow.</p>
<p><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></p>
<ul>
<li><p>Walk through the full authentication flow I designed</p>
</li>
<li><p>Explain the decision tree (passwordless enabled vs not)</p>
</li>
<li><p>Role of the backend vs browser vs authenticator</p>
</li>
<li><p>Why each branch exists</p>
</li>
</ul>
<p><strong>Article 8 — Implementing WebAuthn in Practice</strong></p>
<ul>
<li><p>Tooling used (e.g. WebAuthn server libs, client helpers)</p>
</li>
<li><p>Data models (credential ID, public key, counters)</p>
</li>
<li><p>Common implementation pitfalls</p>
</li>
<li><p>What surprised me during implementation</p>
</li>
</ul>
<p><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></p>
<ul>
<li><p>Why Feide was chosen</p>
</li>
<li><p>How OIDC fits without undermining passwordless</p>
</li>
<li><p>Security boundaries between IdP and my system</p>
</li>
<li><p>Account linking considerations</p>
</li>
</ul>
<hr />
<h3 id="heading-part-v-reflection-amp-lessons-learned"><strong>Part V — Reflection &amp; lessons learned</strong></h3>
<p>Goal: Help readers avoid future mistakes.</p>
<p><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></p>
<ul>
<li><p>Trade-offs I accepted knowingly</p>
</li>
<li><p>Things that looked good on paper but failed in reality</p>
</li>
<li><p>Operational lessons (support, monitoring, edge cases)</p>
</li>
</ul>
<hr />
<h2 id="heading-optional-supplemental-articles">Optional supplemental articles</h2>
<ul>
<li><p><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></p>
<ul>
<li><p>Explain why fallback (OIDC, IdPs, recovery flows) is a <strong>design requirement</strong>, not a compromise</p>
</li>
<li><p>Connect passwordless to enrollment, recovery, device loss, and federation</p>
</li>
<li><p>Show architectural maturity without diving into APIs</p>
</li>
</ul>
</li>
<li><p><strong>How Browser UX Shapes Security More Than Cryptography</strong></p>
<ul>
<li><p>How browser and OS UX decisions constrain authentication design</p>
</li>
<li><p>Why the same WebAuthn flow feels different in Chrome, Safari, and mobile OSes</p>
</li>
<li><p>How retries, error messages, and permission dialogs affect security outcomes</p>
</li>
<li><p>Why good UX prevents insecure workarounds more effectively than stronger algorithms</p>
</li>
</ul>
</li>
</ul>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p>→ Introduction</p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/authentication-is-not-login"><strong>Article 1 — Authentication is Not Login</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/what-passwordless-actually-means"><strong>Article 2 — What “Passwordless” Actually Means</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/webauthn-and-fido2-explained-without-the-spec"><strong>Article 3 — WebAuthn &amp; FIDO2, Explained Without the Spec</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/openid-connect-as-the-glue"><strong>Article 4 — OpenID Connect as the Glue</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/designing-a-passwordless-first-pwa-architecture"><strong>Article 5 — Designing a Passwordless-First PWA Architecture</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/ux-and-failure-are-part-of-the-security-model"><strong>Article 6 — UX and Failure Are Part of the Security Model</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-pwa-flow-architecture-walkthrough"><strong>Article 7 — A Real Passwordless PWA Flow (Architecture Walkthrough)</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/implementing-webauthn-in-practice"><strong>Article 8 — Implementing WebAuthn in Practice</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/integrating-oidc-feide-as-fallback-and-recovery"><strong>Article 9 — Integrating OIDC (Feide) as Fallback and Recovery</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/passwordless-what-worked-what-didnt-what-id-change"><strong>Article 10 — What Worked, What Didn’t, What I’d Change</strong></a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/why-passwordless-alone-is-not-an-identity-strategy"><strong>Why Passwordless Alone Is Not an Identity Strategy</strong></a></p>
</li>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/how-browser-ux-shapes-security-more-than-cryptography"><strong>How Browser UX Shapes Security More Than Cryptography</strong></a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Cron vs Queue vs Event: Choosing the Right Trigger]]></title><description><![CDATA[Most systems don’t fail because they picked the wrong database or framework. They fail because they picked the wrong trigger.
Something runs too early. Or too late. Or too often. Or only when a user happens to click a button. The code is correct, the...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/cron-vs-queue-vs-event-choosing-the-right-trigger</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/cron-vs-queue-vs-event-choosing-the-right-trigger</guid><category><![CDATA[cronjob]]></category><category><![CDATA[Job Queue]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[System Design]]></category><category><![CDATA[BackendArchitecture ]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 01 Feb 2026 04:39:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769920068953/680cfe1b-de24-4ccf-a4a9-eaf0da270950.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most systems don’t fail because they picked the wrong database or framework. They fail because they picked the <strong>wrong trigger</strong>.</p>
<p>Something runs too early. Or too late. Or too often. Or only when a user happens to click a button. The code is correct, the infrastructure is healthy—and the behavior is still wrong.</p>
<p>This article is about learning to choose <em>how work starts</em>. Not how it’s written, not how it’s optimized, but <strong>what causes it to run in the first place</strong>. The frameworks and tools code examples used in this article are derived from my real production implementation.</p>
<p>Cron, queues, and events are not interchangeable. They encode different assumptions about time, causality, and responsibility. Understanding those assumptions is how you avoid architectural debt that only becomes visible years later.</p>
<hr />
<h2 id="heading-three-triggers-three-worldviews">Three Triggers, Three Worldviews</h2>
<p>At a high level, these mechanisms answer different questions:</p>
<ul>
<li><p><strong>Cron</strong> answers: <em>“Is it time?”</em></p>
</li>
<li><p><strong>Queue</strong> answers: <em>“Is there work waiting?”</em></p>
</li>
<li><p><strong>Event</strong> answers: <em>“Did something happen?”</em></p>
</li>
</ul>
<p>They may all execute code, but they live in different mental models.</p>
<hr />
<h2 id="heading-time-triggered-execution-cron">Time-Triggered Execution (Cron)</h2>
<p>Cron is <strong>time-driven</strong>. It does not care whether anything changed. It cares only that the clock says “now.”</p>
<p>In a Yii/HumHub setup, this often looks like:</p>
<pre><code class="lang-bash">* * * * * php yii cron/run
</code></pre>
<p>Inside the application:</p>
<pre><code class="lang-php"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CleanupJob</span> <span class="hljs-keyword">extends</span> \<span class="hljs-title">humhub</span>\<span class="hljs-title">components</span>\<span class="hljs-title">CronJob</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
    </span>{
        Post::deleteAll([<span class="hljs-string">'&lt;'</span>, <span class="hljs-string">'created_at'</span>, strtotime(<span class="hljs-string">'-30 days'</span>)]);
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getSchedule</span>(<span class="hljs-params"></span>)
    </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-built_in">self</span>::SCHEDULE_DAILY;
    }
}
</code></pre>
<p>Cron is ideal when:</p>
<ul>
<li><p>Work must happen <em>even if nothing else happens</em></p>
</li>
<li><p>You are reconciling or enforcing invariants</p>
</li>
<li><p>You are comfortable with best-effort timing</p>
</li>
<li><p>Skipped runs are acceptable or recoverable</p>
</li>
</ul>
<p>Cron is <strong>indifferent</strong>. It runs whether the system is busy or idle, healthy or degraded.</p>
<p>That indifference is its power—and its danger.</p>
<hr />
<h2 id="heading-work-triggered-execution-queues">Work-Triggered Execution (Queues)</h2>
<p>Queues are <strong>state-driven</strong>. They execute because work exists, not because time passed.</p>
<p>In Yii:</p>
<pre><code class="lang-php">Yii::$app-&gt;queue-&gt;push(<span class="hljs-keyword">new</span> SendNotificationJob([
    <span class="hljs-string">'userId'</span> =&gt; $userId,
]));
</code></pre>
<p>And later:</p>
<pre><code class="lang-bash">php yii queue/run
</code></pre>
<p>Queues are ideal when:</p>
<ul>
<li><p>Work volume is unpredictable</p>
</li>
<li><p>You need retries, delays, or prioritization</p>
</li>
<li><p>Execution should scale independently of scheduling</p>
</li>
<li><p>Latency matters more than exact timing</p>
</li>
</ul>
<p>Queues care deeply about <strong>backlog</strong>. If nothing is waiting, nothing runs. If a lot is waiting, they absorb pressure instead of collapsing.</p>
<p>Queues answer <em>how much</em> and <em>how fast</em>. They do not answer <em>when</em> something should be considered in the first place.</p>
<hr />
<h2 id="heading-event-driven-execution">Event-Driven Execution</h2>
<p>Events are <strong>causality-driven</strong>. They run because <em>something specific happened</em>.</p>
<p>In application code:</p>
<pre><code class="lang-php">Event::trigger(User::class, User::EVENT_AFTER_INSERT, <span class="hljs-keyword">new</span> Event([
    <span class="hljs-string">'sender'</span> =&gt; $user,
]));
</code></pre>
<p>Or conceptually:</p>
<pre><code class="lang-php">onUserRegistered($user) {
    <span class="hljs-comment">// react immediately</span>
}
</code></pre>
<p>Events are ideal when:</p>
<ul>
<li><p>A specific state change matters</p>
</li>
<li><p>The reaction must be immediate or contextual</p>
</li>
<li><p>You want minimal latency</p>
</li>
<li><p>You can tolerate missed events only rarely</p>
</li>
</ul>
<p>Events encode <strong>meaning</strong>, not schedule. They answer <em>why</em> something should run.</p>
<p>But events are fragile when used for work that must happen regardless of user behavior.</p>
<hr />
<h2 id="heading-a-simple-decision-matrix">A Simple Decision Matrix</h2>
<p>When deciding how to trigger work, ask these questions:</p>
<h3 id="heading-1-does-this-need-to-happen-even-if-nobody-does-anything">1. Does this need to happen even if nobody does anything?</h3>
<ul>
<li><p><strong>Yes</strong> → Cron</p>
</li>
<li><p><strong>No</strong> → Queue or Event</p>
</li>
</ul>
<h3 id="heading-2-does-this-need-to-happen-immediately-when-something-changes">2. Does this need to happen immediately when something changes?</h3>
<ul>
<li><p><strong>Yes</strong> → Event</p>
</li>
<li><p><strong>No</strong> → Cron or Queue</p>
</li>
</ul>
<h3 id="heading-3-is-the-amount-of-work-unpredictable-or-bursty">3. Is the amount of work unpredictable or bursty?</h3>
<ul>
<li><p><strong>Yes</strong> → Queue</p>
</li>
<li><p><strong>No</strong> → Cron or Event</p>
</li>
</ul>
<h3 id="heading-4-can-this-safely-run-twice">4. Can this safely run twice?</h3>
<ul>
<li><p><strong>No</strong> → Avoid cron unless idempotency or locking exists</p>
</li>
<li><p><strong>Yes</strong> → Cron becomes viable</p>
</li>
</ul>
<h3 id="heading-5-is-time-the-reason-this-work-exists">5. Is time the reason this work exists?</h3>
<ul>
<li><p><strong>Yes</strong> → Cron</p>
</li>
<li><p><strong>No</strong> → Queue or Event</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769920525465/8e19a90b-eb81-4d77-9a5d-aa79cd42eb3d.png" alt class="image--center mx-auto" /></p>
<p>Most bad designs come from answering these questions incorrectly—or not asking them at all.</p>
<hr />
<h2 id="heading-hybrid-patterns-where-real-systems-live">Hybrid Patterns (Where Real Systems Live)</h2>
<p>Mature systems rarely choose just one trigger. They <strong>compose</strong> them.</p>
<h3 id="heading-pattern-1-cron-queue-time-decides-queue-executes">Pattern 1: Cron → Queue (Time Decides, Queue Executes)</h3>
<p>This is the HumHub pattern that I’ve implemented.</p>
<pre><code class="lang-bash">* * * * * php yii cron/run
</code></pre>
<pre><code class="lang-php"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
</span>{
    Yii::$app-&gt;queue-&gt;push(<span class="hljs-keyword">new</span> RecalculateStatsJob());
}
</code></pre>
<p>Cron decides <em>when</em>.<br />The queue decides <em>how fast</em>.</p>
<p>This is ideal for periodic but heavy work.</p>
<h3 id="heading-pattern-2-event-queue-meaning-decides-queue-executes">Pattern 2: Event → Queue (Meaning Decides, Queue Executes)</h3>
<pre><code class="lang-php">Event::on(User::class, User::EVENT_AFTER_INSERT, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">$event</span>) </span>{
    Yii::$app-&gt;queue-&gt;push(<span class="hljs-keyword">new</span> SendWelcomeEmailJob([
        <span class="hljs-string">'userId'</span> =&gt; $event-&gt;sender-&gt;id,
    ]));
});
</code></pre>
<p>The event provides context.<br />The queue provides resilience.</p>
<p>This keeps user-facing actions fast while preserving intent.</p>
<h3 id="heading-pattern-3-event-cron-immediate-reconciliation">Pattern 3: Event + Cron (Immediate + Reconciliation)</h3>
<p>Events do the fast path. Cron does the safety net.</p>
<pre><code class="lang-php"><span class="hljs-comment">// Event-driven</span>
onOrderPaid($order) {
    markAsProcessed($order);
}

<span class="hljs-comment">// Cron-driven reconciliation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderReconciliationJob</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">CronJob</span> </span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-keyword">$this</span>-&gt;fixInconsistentOrders();
    }
}
</code></pre>
<p>This pattern accepts that events can be missed and uses time-based checks to restore correctness.</p>
<p>This is not redundancy. It is <strong>defensive architecture</strong>.</p>
<hr />
<h2 id="heading-why-misuse-creates-architectural-debt">Why Misuse Creates Architectural Debt</h2>
<p>The most expensive mistakes are subtle.</p>
<h3 id="heading-using-cron-for-event-driven-work">Using Cron for Event-Driven Work</h3>
<p>Example:</p>
<blockquote>
<p>“Send emails every minute and see who signed up.”</p>
</blockquote>
<p>Problems:</p>
<ul>
<li><p>Unnecessary polling</p>
</li>
<li><p>Delayed reactions</p>
</li>
<li><p>Growing query cost</p>
</li>
<li><p>Confusing intent</p>
</li>
</ul>
<p>You encoded <em>meaning</em> (user signed up) as <em>time</em> (every minute). That mismatch becomes technical debt.</p>
<h3 id="heading-using-events-for-time-based-guarantees">Using Events for Time-Based Guarantees</h3>
<p>Example:</p>
<blockquote>
<p>“Clean up expired sessions when users log in.”</p>
</blockquote>
<p>What if nobody logs in?</p>
<p>Now cleanup depends on behavior unrelated to the task’s purpose. That’s accidental coupling.</p>
<h3 id="heading-using-queues-as-a-scheduler">Using Queues as a Scheduler</h3>
<p>Example:</p>
<blockquote>
<p>“Push a delayed job and hope it fires at the right time.”</p>
</blockquote>
<p>Queues are not clocks. Delays drift. Retries compound. Restarts blur guarantees.</p>
<p>Time-based intent belongs to a scheduler, not a backlog.</p>
<hr />
<h2 id="heading-the-core-insight-triggers-encode-assumptions">The Core Insight: Triggers Encode Assumptions</h2>
<p>Every trigger bakes in assumptions:</p>
<ul>
<li><p><strong>Cron</strong> assumes time is the reason work exists</p>
</li>
<li><p><strong>Queues</strong> assume work volume is the problem</p>
</li>
<li><p><strong>Events</strong> assume causality is the signal</p>
</li>
</ul>
<p>If those assumptions are wrong, the system <em>still works</em>—just increasingly badly.</p>
<p>That’s why misuse is so dangerous. It doesn’t break immediately. It <strong>ages poorly</strong>.</p>
<hr />
<h2 id="heading-designing-instead-of-guessing">Designing Instead of Guessing</h2>
<p>Instead of asking:</p>
<blockquote>
<p>“How should I run this code?”</p>
</blockquote>
<p>Ask:</p>
<blockquote>
<p>“Why should this code run at all?”</p>
</blockquote>
<ul>
<li><p>If the answer is <em>“because time passed”</em> → Cron</p>
</li>
<li><p>If the answer is <em>“because work exists”</em> → Queue</p>
</li>
<li><p>If the answer is <em>“because something happened”</em> → Event</p>
</li>
</ul>
<p>Only after that do frameworks, tools, and syntax matter.</p>
<hr />
<h2 id="heading-the-takeaway">The Takeaway</h2>
<p>Cron, queues, and events are not competitors. They are <strong>orthogonal tools</strong>.</p>
<p>Good systems:</p>
<ul>
<li><p>Use cron sparingly and deliberately</p>
</li>
<li><p>Let queues absorb pressure</p>
</li>
<li><p>Let events carry meaning</p>
</li>
<li><p>Combine them where guarantees matter</p>
</li>
</ul>
<p>Bad systems pick one and force everything through it.</p>
<p>Choosing the right trigger is not an implementation detail.<br />It’s an architectural commitment.</p>
<p>And once you see that clearly, entire classes of problems stop appearing—not because you fixed them, but because you never created them in the first place.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-understanding-cron-from-first-principles-to-production"><strong>Introduction</strong></a></p>
</li>
<li><p><strong>Part 1:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-the-invisible-operating-system">Cron: The Invisible Operating System</a></p>
</li>
<li><p><strong>Part 2:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/anatomy-of-a-cron-job">Anatomy of a Cron Job</a></p>
</li>
<li><p><strong>Part 3:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-at-scale-patterns-and-anti-patterns">Cron at Scale: Patterns and Anti-Patterns</a></p>
</li>
<li><p><strong>Part 4:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-in-frameworks-from-theory-to-convention">Cron in Frameworks: From Theory to Convention</a></p>
</li>
<li><p><strong>Part 5:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/humhub-and-yii-design-intent-behind-the-cron-architecture">HumHub &amp; Yii: Design Intent Behind the Cron Architecture</a></p>
</li>
<li><p><strong>Part 6:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-production-setup-what-i-actually-built">A Real Production Setup: What I Actually Built</a></p>
</li>
<li><p><strong>Part 7:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-failure-modes-tradeoffs-and-lessons-learned">Failure Modes, Tradeoffs, and Lessons Learned</a></p>
</li>
<li><p><strong>Part 8:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/the-evolution-path-from-cron-to-orchestration">The Evolution Path: From Cron to Orchestration</a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p>⏳ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-lies-when-scheduled-jobs-dont-run">Cron Lies: When Scheduled Jobs Don’t Run</a></p>
</li>
<li><p>🔁 <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/idempotency-the-most-important-word-in-cron-youre-probably-ignoring">Idempotency: The Most Important Word in Cron</a></p>
</li>
<li><p>→ ⚖️ Cron vs Queue vs Event</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Idempotency: The Most Important Word in Cron You’re Probably Ignoring]]></title><description><![CDATA[If cron has a single moral lesson, it’s this: time does not guarantee uniqueness.
Jobs run twice. Or zero times. Or half a time. Or later than expected. Cron does not promise exactly once execution, and every system that assumes it does eventually pa...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/idempotency-the-most-important-word-in-cron-youre-probably-ignoring</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/idempotency-the-most-important-word-in-cron-youre-probably-ignoring</guid><category><![CDATA[cronjob]]></category><category><![CDATA[idempotence]]></category><category><![CDATA[BackendArchitecture ]]></category><category><![CDATA[System Design]]></category><category><![CDATA[Production Systems]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Yii2]]></category><category><![CDATA[Humhub]]></category><category><![CDATA[asynchronous-jobs]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 01 Feb 2026 04:18:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769918705878/9c3654a7-289e-4f4c-b996-0cb58c388f1f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If cron has a single moral lesson, it’s this: <strong>time does not guarantee uniqueness</strong>.</p>
<p>Jobs run twice. Or zero times. Or half a time. Or later than expected. Cron does not promise <em>exactly once</em> execution, and every system that assumes it does eventually pays for that assumption.</p>
<p>Idempotency is how you survive that reality.</p>
<p>This article unpacks what idempotency really means, why cron jobs <em>must</em> be idempotent, what goes wrong when they aren’t, and how to design idempotent jobs specifically in <strong>Yii</strong> and <strong>HumHub</strong>, based on my implemented real production patterns—not theory.</p>
<hr />
<h2 id="heading-what-idempotency-actually-means-without-hand-waving">What Idempotency Actually Means (Without Hand-Waving)</h2>
<p>A piece of logic is <strong>idempotent</strong> if running it multiple times produces the <strong>same final state</strong> as running it once.</p>
<p>Not “similar.”<br />Not “probably fine.”<br />The <em>same</em>.</p>
<p>Classic examples:</p>
<ul>
<li><p>Setting a value: <code>status = 'active'</code></p>
</li>
<li><p>Rebuilding an index from source-of-truth data</p>
</li>
<li><p>Deleting records older than a cutoff date</p>
</li>
</ul>
<p>Non-idempotent actions:</p>
<ul>
<li><p>Incrementing counters</p>
</li>
<li><p>Sending emails</p>
</li>
<li><p>Charging money</p>
</li>
<li><p>Appending rows blindly</p>
</li>
<li><p>“Process everything since last run” without state</p>
</li>
</ul>
<p>Idempotency is not about <em>how often</em> something runs.<br />It’s about <em>what happens when it runs again</em>.</p>
<p>Cron forces this distinction because <strong>repetition is normal, not exceptional</strong>.</p>
<hr />
<h2 id="heading-why-cron-jobs-must-be-idempotent">Why Cron Jobs Must Be Idempotent</h2>
<p>Cron has no memory (or stateless, in more technical term).</p>
<p>It does not know:</p>
<ul>
<li><p>Whether a job ran before</p>
</li>
<li><p>Whether it finished</p>
</li>
<li><p>Whether it partially failed</p>
</li>
<li><p>Whether it overlapped with itself</p>
</li>
<li><p>Whether the system was down for hours</p>
</li>
</ul>
<p>From cron’s point of view, every run is a fresh attempt.</p>
<p>That means <strong>every cron job must assume</strong>:</p>
<ul>
<li><p>It may run twice</p>
</li>
<li><p>It may run late</p>
</li>
<li><p>It may run concurrently</p>
</li>
<li><p>It may be retried manually</p>
</li>
<li><p>It may be restarted mid-execution</p>
</li>
</ul>
<p>If a job cannot tolerate these conditions, it is fragile by definition.</p>
<p>Idempotency is not a “best practice” here.<br />It is a <strong>precondition for correctness</strong>.</p>
<hr />
<h2 id="heading-real-non-idempotent-disasters-all-painfully-common">Real Non-Idempotent Disasters (All Painfully Common)</h2>
<h3 id="heading-1-duplicate-emails">1. Duplicate Emails</h3>
<pre><code class="lang-php"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
</span>{
    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">$this</span>-&gt;getUsersToNotify() <span class="hljs-keyword">as</span> $user) {
        <span class="hljs-keyword">$this</span>-&gt;mailer-&gt;send($user);
    }
}
</code></pre>
<p>What happens when:</p>
<ul>
<li><p>The job overlaps?</p>
</li>
<li><p>Someone reruns it manually?</p>
</li>
<li><p>The server restarts mid-run?</p>
</li>
</ul>
<p>Users get two emails. Or three. Or five.</p>
<p>Nothing crashes. Logs look normal. Trust erodes quietly.</p>
<h3 id="heading-2-double-charges-double-credits">2. Double Charges / Double Credits</h3>
<pre><code class="lang-php"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
</span>{
    $orders = Order::find()-&gt;where([<span class="hljs-string">'status'</span> =&gt; <span class="hljs-string">'pending'</span>])-&gt;all();

    <span class="hljs-keyword">foreach</span> ($orders <span class="hljs-keyword">as</span> $order) {
        <span class="hljs-keyword">$this</span>-&gt;charge($order);
        $order-&gt;status = <span class="hljs-string">'paid'</span>;
        $order-&gt;save();
    }
}
</code></pre>
<p>If the process crashes after <code>charge()</code> but before <code>save()</code>:</p>
<ul>
<li><p>Next run charges again</p>
</li>
<li><p>Status still says <code>pending</code></p>
</li>
<li><p>You now have financial damage, not a bug</p>
</li>
</ul>
<p>This is how companies end up writing apology emails.</p>
<h3 id="heading-3-since-last-run-logic-without-state">3. “Since Last Run” Logic Without State</h3>
<pre><code class="lang-php">$lastRun = strtotime(<span class="hljs-string">'-1 hour'</span>);
$items = Item::find()-&gt;where([<span class="hljs-string">'&gt;'</span>, <span class="hljs-string">'created_at'</span>, $lastRun])-&gt;all();
</code></pre>
<p>This assumes:</p>
<ul>
<li><p>The job ran exactly one hour ago</p>
</li>
<li><p>Time moved forward smoothly</p>
</li>
<li><p>No downtime occurred</p>
</li>
</ul>
<p>All three assumptions are false in production.</p>
<hr />
<h2 id="heading-the-mental-shift-cron-jobs-are-reconciliation-jobs">The Mental Shift: Cron Jobs Are Reconciliation Jobs</h2>
<p>The safest cron jobs don’t <em>process events</em>.<br />They <strong>reconcile state</strong>.</p>
<p>Instead of:</p>
<blockquote>
<p>“Do X for everything that happened since last time”</p>
</blockquote>
<p>Think:</p>
<blockquote>
<p>“Given the current truth, what should the system look like now?”</p>
</blockquote>
<p>That shift is the heart of idempotency.</p>
<hr />
<h2 id="heading-yii-amp-humhub-practical-idempotency-patterns">Yii &amp; HumHub: Practical Idempotency Patterns</h2>
<p>Let’s move from theory to code. Yii &amp; Humhub are used to powered my real production implementation.</p>
<h3 id="heading-1-use-state-as-a-guard-not-time">1. Use State as a Guard, Not Time</h3>
<p>Instead of tracking <em>when</em> something last ran, track <em>what has already been done</em>.</p>
<pre><code class="lang-php">$users = User::find()
    -&gt;where([<span class="hljs-string">'notification_sent'</span> =&gt; <span class="hljs-literal">false</span>])
    -&gt;all();

<span class="hljs-keyword">foreach</span> ($users <span class="hljs-keyword">as</span> $user) {
    <span class="hljs-keyword">$this</span>-&gt;sendNotification($user);
    $user-&gt;notification_sent = <span class="hljs-literal">true</span>;
    $user-&gt;save();
}
</code></pre>
<p>Now:</p>
<ul>
<li><p>Reruns do nothing</p>
</li>
<li><p>Partial runs resume safely</p>
</li>
<li><p>Overlaps converge on the same result</p>
</li>
</ul>
<p>This is idempotency via <strong>explicit state</strong>.</p>
<h3 id="heading-2-make-jobs-self-skipping">2. Make Jobs Self-Skipping</h3>
<p>In HumHub-style cron jobs, it’s completely valid for a job to decide it has nothing to do.</p>
<pre><code class="lang-php"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
</span>{
    <span class="hljs-keyword">if</span> (!<span class="hljs-keyword">$this</span>-&gt;hasWorkToDo()) {
        Yii::info(<span class="hljs-string">'CleanupJob: nothing to clean'</span>);
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">$this</span>-&gt;cleanup();
}
</code></pre>
<p>A job that runs and does nothing is <strong>successful</strong>, not broken.</p>
<p>Idempotent jobs are comfortable with no-ops.</p>
<h3 id="heading-3-use-unique-constraints-as-a-safety-net">3. Use Unique Constraints as a Safety Net</h3>
<p>Databases are excellent idempotency enforcers.</p>
<pre><code class="lang-sql">UNIQUE (user_id, notification_type)
</code></pre>
<pre><code class="lang-php"><span class="hljs-keyword">try</span> {
    Notification::create([
        <span class="hljs-string">'user_id'</span> =&gt; $userId,
        <span class="hljs-string">'type'</span> =&gt; <span class="hljs-string">'weekly_summary'</span>,
    ]);
} <span class="hljs-keyword">catch</span> (\yii\db\<span class="hljs-built_in">Exception</span> $e) {
    <span class="hljs-comment">// Already exists → safe to ignore</span>
}
</code></pre>
<p>Now:</p>
<ul>
<li><p>Double execution collapses into one record</p>
</li>
<li><p>The database enforces correctness</p>
</li>
<li><p>Your job logic stays simple</p>
</li>
</ul>
<p>This is one of the strongest patterns available.</p>
<h3 id="heading-4-lock-only-when-you-must">4. Lock Only When You Must</h3>
<p>Idempotency reduces the need for locks—but doesn’t eliminate it.</p>
<p>For jobs that <strong>must not overlap</strong>:</p>
<pre><code class="lang-php">$lock = Yii::$app-&gt;mutex;

<span class="hljs-keyword">if</span> (!$lock-&gt;acquire(<span class="hljs-string">'daily_cleanup'</span>, <span class="hljs-number">0</span>)) {
    <span class="hljs-keyword">return</span>;
}

<span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">$this</span>-&gt;cleanup();
} <span class="hljs-keyword">finally</span> {
    $lock-&gt;release(<span class="hljs-string">'daily_cleanup'</span>);
}
</code></pre>
<p>Locks prevent concurrency.<br />Idempotency prevents damage.</p>
<p>They solve different problems. Use both intentionally.</p>
<h3 id="heading-5-rebuild-dont-mutate-when-possible">5. Rebuild, Don’t Mutate, When Possible</h3>
<p>The most robust cron jobs rebuild derived data from scratch.</p>
<pre><code class="lang-php">SearchIndex::deleteAll();

<span class="hljs-keyword">foreach</span> (Post::find()-&gt;all() <span class="hljs-keyword">as</span> $post) {
    SearchIndex::index($post);
}
</code></pre>
<p>This feels inefficient—but it’s <em>correct</em>.</p>
<p>If the source of truth is reliable, rebuilding is naturally idempotent.</p>
<p>Optimization can come later. Correctness cannot.</p>
<hr />
<h2 id="heading-why-idempotency-is-rarely-explained-well">Why Idempotency Is Rarely Explained Well</h2>
<p>Because it’s uncomfortable.</p>
<p>It forces you to confront:</p>
<ul>
<li><p>Partial failure</p>
</li>
<li><p>Repetition</p>
</li>
<li><p>Uncertainty</p>
</li>
<li><p>The illusion of control</p>
</li>
</ul>
<p>Most tutorials assume:</p>
<ul>
<li><p>Jobs run once</p>
</li>
<li><p>Systems are up</p>
</li>
<li><p>Time is reliable</p>
</li>
<li><p>Humans don’t rerun things</p>
</li>
</ul>
<p>Production assumes none of that.</p>
<p>Idempotency is not glamorous.<br />It doesn’t show up in benchmarks.<br />But it quietly prevents disasters.</p>
<hr />
<h2 id="heading-why-this-is-immediately-practical">Why This Is Immediately Practical</h2>
<p>Once you start asking:</p>
<blockquote>
<p>“What happens if this runs twice?”</p>
</blockquote>
<p>Your design changes immediately.</p>
<ul>
<li><p>You stop using counters blindly</p>
</li>
<li><p>You stop trusting timestamps</p>
</li>
<li><p>You stop assuming order</p>
</li>
<li><p>You start encoding intent in state</p>
</li>
</ul>
<p>Cron becomes safer overnight—not because cron changed, but because <em>your thinking did</em>.</p>
<hr />
<h2 id="heading-the-real-takeaway">The Real Takeaway</h2>
<p>Cron doesn’t punish non-idempotent code instantly.<br />It waits.</p>
<p>It lets the system run.<br />It lets data accumulate.<br />It lets assumptions fossilize.</p>
<p>Then one day:</p>
<ul>
<li><p>A server restarts</p>
</li>
<li><p>A job overlaps</p>
</li>
<li><p>Someone reruns a command</p>
</li>
</ul>
<p>And the damage appears all at once.</p>
<p>Idempotency is how you make cron boring again.</p>
<p>And boring, in production systems, is the highest compliment there is.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-understanding-cron-from-first-principles-to-production"><strong>Introduction</strong></a></p>
</li>
<li><p><strong>Part 1:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-the-invisible-operating-system">Cron: The Invisible Operating System</a></p>
</li>
<li><p><strong>Part 2:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/anatomy-of-a-cron-job">Anatomy of a Cron Job</a></p>
</li>
<li><p><strong>Part 3:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-at-scale-patterns-and-anti-patterns">Cron at Scale: Patterns and Anti-Patterns</a></p>
</li>
<li><p><strong>Part 4:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-in-frameworks-from-theory-to-convention">Cron in Frameworks: From Theory to Convention</a></p>
</li>
<li><p><strong>Part 5:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/humhub-and-yii-design-intent-behind-the-cron-architecture">HumHub &amp; Yii: Design Intent Behind the Cron Architecture</a></p>
</li>
<li><p><strong>Part 6:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-production-setup-what-i-actually-built">A Real Production Setup: What I Actually Built</a></p>
</li>
<li><p><strong>Part 7:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-failure-modes-tradeoffs-and-lessons-learned">Failure Modes, Tradeoffs, and Lessons Learned</a></p>
</li>
<li><p><strong>Part 8:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/the-evolution-path-from-cron-to-orchestration">The Evolution Path: From Cron to Orchestration</a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p>⏳ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-lies-when-scheduled-jobs-dont-run">Cron Lies: When Scheduled Jobs Don’t Run</a></p>
</li>
<li><p>→ 🔁 Idempotency: The Most Important Word in Cron</p>
</li>
<li><p>⚖️ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-vs-queue-vs-event-choosing-the-right-trigger">Cron vs Queue vs Event</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Cron Lies: When Scheduled Jobs Don’t Run]]></title><description><![CDATA[Cron has a reputation for honesty. You tell it when, it runs then. If something didn’t happen, the instinctive conclusion is: “the code must be broken.”
In production, that assumption is often wrong.
This article is about the more uncomfortable truth...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/cron-lies-when-scheduled-jobs-dont-run</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/cron-lies-when-scheduled-jobs-dont-run</guid><category><![CDATA[silent-failures]]></category><category><![CDATA[cronjob]]></category><category><![CDATA[scheduling]]></category><category><![CDATA[System Design]]></category><category><![CDATA[Production Systems]]></category><category><![CDATA[Devops]]></category><category><![CDATA[BackendArchitecture ]]></category><category><![CDATA[time-based-systems]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 01 Feb 2026 03:53:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769315995794/ee048acc-49f1-47fa-bb90-9f6c8a7df40a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cron has a reputation for honesty. You tell it <em>when</em>, it runs <em>then</em>. If something didn’t happen, the instinctive conclusion is: “the code must be broken.”</p>
<p>In production, that assumption is often wrong.</p>
<p>This article is about the more uncomfortable truth: <strong>cron can fail without lying to you explicitly</strong>. Jobs don’t run, or don’t run when you think they do, and the system emits no signal strong enough to attract attention. Everything looks calm. Until a human notices a missing effect.</p>
<p>This is where defensive thinking begins.</p>
<hr />
<h2 id="heading-the-most-dangerous-failure-mode-silence">The Most Dangerous Failure Mode: Silence</h2>
<p>Cron almost never crashes loudly. It fails quietly.</p>
<p>If a scheduled job:</p>
<ul>
<li><p>Never executes</p>
</li>
<li><p>Executes under the wrong conditions</p>
</li>
<li><p>Executes later than expected</p>
</li>
<li><p>Executes and does nothing</p>
</li>
</ul>
<p>…the system often produces the same observable result: <em>nothing happens</em>.</p>
<p>That silence is what makes cron failures expensive. They age quietly.</p>
<hr />
<h2 id="heading-every-minute-is-a-filter-not-a-promise">“Every Minute” Is a Filter, Not a Promise</h2>
<p>Let’s start with the most common misunderstanding:</p>
<pre><code class="lang-bash">* * * * * php yii cron/run
</code></pre>
<p>This does <strong>not</strong> mean:</p>
<blockquote>
<p>“This command will run every 60 seconds, no matter what.”</p>
</blockquote>
<p>What it actually means:</p>
<blockquote>
<p>“At each minute boundary where the system clock matches this expression, attempt execution.”</p>
</blockquote>
<p>That distinction matters more than most people realize.</p>
<p>Cron does not:</p>
<ul>
<li><p>Track last execution time</p>
</li>
<li><p>Compensate for downtime</p>
</li>
<li><p>Retry missed runs</p>
</li>
<li><p>Guarantee spacing between runs</p>
</li>
</ul>
<p>It evaluates the <em>current time</em>, not elapsed time.</p>
<p>If the system clock jumps forward, backward, or disappears entirely for a while, cron does not reconcile history. It lives in the present tense only.</p>
<hr />
<h2 id="heading-downtime-windows-the-lie-of-continuity">Downtime Windows: The Lie of Continuity</h2>
<p>In my real production setup, this constraint existed:</p>
<blockquote>
<p><strong>Development and UAT servers were shut down daily from 00:00 to 08:00 SGT</strong></p>
</blockquote>
<p>During that window:</p>
<ul>
<li><p>No cron daemon</p>
</li>
<li><p>No <code>cron/run</code></p>
</li>
<li><p>No <code>queue/run</code></p>
</li>
</ul>
<p>From cron’s perspective, <strong>those minutes never existed</strong>.</p>
<p>Consider an interval job inside HumHub (which powered my real production web app) that conceptually runs “hourly”:</p>
<pre><code class="lang-php"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CleanupJob</span> <span class="hljs-keyword">extends</span> \<span class="hljs-title">humhub</span>\<span class="hljs-title">components</span>\<span class="hljs-title">CronJob</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
    </span>{
        <span class="hljs-comment">// cleanup logic</span>
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getSchedule</span>(<span class="hljs-params"></span>)
    </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-built_in">self</span>::SCHEDULE_HOURLY;
    }
}
</code></pre>
<p>If this job was due at:</p>
<ul>
<li><p>01:00</p>
</li>
<li><p>02:00</p>
</li>
<li><p>03:00</p>
</li>
</ul>
<p>…and the server was down?</p>
<p>Those executions are not “late.”<br />They are <strong>lost</strong>.</p>
<p>When the server boots at 08:00:</p>
<ul>
<li><p><code>cron/run</code> evaluates <em>now</em></p>
</li>
<li><p>The job decides whether <em>now</em> matches its schedule</p>
</li>
<li><p>Missed intent is not replayed unless explicitly coded</p>
</li>
</ul>
<p>This is not a bug. It’s a property.</p>
<h3 id="heading-the-defensive-lesson">The Defensive Lesson</h3>
<p>If a job <em>must</em> run for every interval, cron alone is insufficient.<br />You need state.</p>
<p>If a job <em>can tolerate skips</em>, cron is perfectly adequate—but only if you consciously design for that tolerance.</p>
<hr />
<h2 id="heading-silent-failure-1-disabled-cron-services">Silent Failure #1: Disabled Cron Services</h2>
<p>One of the most insidious cron failures is also the simplest:</p>
<blockquote>
<p>The cron daemon is not running.</p>
</blockquote>
<p>This happens more often than people admit:</p>
<ul>
<li><p>Servers reboot</p>
</li>
<li><p>Cron services fail to start</p>
</li>
<li><p>Containers don’t include cron at all</p>
</li>
<li><p>systemd timers are misconfigured</p>
</li>
</ul>
<p>From the application’s point of view:</p>
<ul>
<li><p>Nothing throws an error</p>
</li>
<li><p>No exception is logged</p>
</li>
<li><p>No stack trace exists</p>
</li>
</ul>
<p>My Yii or HumHub code is innocent. It was never invoked.</p>
<h3 id="heading-defensive-pattern">Defensive Pattern</h3>
<p>Treat cron as <em>external infrastructure</em>, not application logic.</p>
<p>At minimum:</p>
<ul>
<li><p>Periodically log “cron heartbeat” execution</p>
</li>
<li><p>Alert if no cron activity is observed for a threshold window</p>
</li>
</ul>
<p>Cron itself will not tell you it is dead.</p>
<hr />
<h2 id="heading-silent-failure-2-output-suppression-without-compensation">Silent Failure #2: Output Suppression Without Compensation</h2>
<p>Your production crontab did this:</p>
<pre><code class="lang-bash">&gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>That choice was intentional and reasonable. But it came with a requirement:</p>
<blockquote>
<p><strong>Every meaningful failure must be logged inside the application.</strong></p>
</blockquote>
<p>If a job does this:</p>
<pre><code class="lang-php"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span>(<span class="hljs-params"></span>)
</span>{
    $result = <span class="hljs-keyword">$this</span>-&gt;callExternalApi();

    <span class="hljs-keyword">if</span> (!$result) {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-comment">// continue</span>
}
</code></pre>
<p>Then a failure looks identical to:</p>
<ul>
<li><p>Job never ran</p>
</li>
<li><p>Job ran but exited early</p>
</li>
<li><p>Job ran and succeeded with no effect</p>
</li>
</ul>
<p>From the outside, all three collapse into silence.</p>
<h3 id="heading-defensive-pattern-1">Defensive Pattern</h3>
<p>Every cron job should answer at least one of these questions <em>explicitly</em>:</p>
<ul>
<li><p>“I ran”</p>
</li>
<li><p>“I skipped myself”</p>
</li>
<li><p>“I failed”</p>
</li>
</ul>
<p>Not necessarily loudly—but <strong>traceably</strong>.</p>
<p>Silence must be meaningful, not ambiguous.</p>
<hr />
<h2 id="heading-silent-failure-3-clock-drift">Silent Failure #3: Clock Drift</h2>
<p>Cron trusts the system clock. Completely.</p>
<p>If the clock is wrong:</p>
<ul>
<li><p>Jobs run at the wrong time</p>
</li>
<li><p>Jobs cluster unexpectedly</p>
</li>
<li><p>Jobs appear to “randomly” skip</p>
</li>
</ul>
<p>Clock drift is especially dangerous because:</p>
<ul>
<li><p>It accumulates slowly</p>
</li>
<li><p>It rarely breaks tests</p>
</li>
<li><p>It rarely shows up in logs</p>
</li>
</ul>
<p>You don’t need extreme drift for damage. A few minutes is enough to:</p>
<ul>
<li><p>Break SLAs</p>
</li>
<li><p>Miss external deadlines</p>
</li>
<li><p>Violate assumptions baked into job logic</p>
</li>
</ul>
<h3 id="heading-defensive-pattern-2">Defensive Pattern</h3>
<p>Time-based systems should:</p>
<ul>
<li><p>Assume time can be wrong</p>
</li>
<li><p>Avoid exact-time equality checks</p>
</li>
<li><p>Prefer ranges over instants</p>
</li>
</ul>
<p>Cron is punctual only relative to the clock it’s given.</p>
<hr />
<h2 id="heading-silent-failure-4-overlap-that-cancels-itself-out">Silent Failure #4: Overlap That Cancels Itself Out</h2>
<p>A job that overlaps itself can fail <em>without error</em>.</p>
<p>Example:</p>
<pre><code class="lang-bash">* * * * * php yii queue/run
</code></pre>
<p>If <code>queue/run</code> takes longer than one minute:</p>
<ul>
<li><p>A second instance starts</p>
</li>
<li><p>Both compete for resources</p>
</li>
<li><p>One may exit early</p>
</li>
<li><p>Or both may do partial work</p>
</li>
</ul>
<p>If jobs are idempotent, this is survivable.<br />If they are not, this is corruption without alarms.</p>
<p>Nothing crashes. No exception bubbles up.<br />The system simply does the wrong thing quietly.</p>
<hr />
<h2 id="heading-the-big-lie-if-it-didnt-happen-something-would-have-alerted">The Big Lie: “If It Didn’t Happen, Something Would Have Alerted”</h2>
<p>Cron does not alert.<br />Cron does not retry.<br />Cron does not remember.</p>
<p>Unless <em>you</em> build signals around it, cron failures degrade into:</p>
<ul>
<li><p>Missing emails</p>
</li>
<li><p>Stale data</p>
</li>
<li><p>Incomplete cleanup</p>
</li>
<li><p>User-visible oddities discovered late</p>
</li>
</ul>
<p>This is why cron failures are so often discovered by humans first.</p>
<hr />
<h2 id="heading-why-this-is-so-valuable-to-learn-early">Why This Is So Valuable to Learn Early</h2>
<p>This article pairs perfectly with the dev/UAT downtime case because it reveals something deeper:</p>
<blockquote>
<p><strong>Cron teaches you what your system actually assumes about time.</strong></p>
</blockquote>
<p>Nightly downtime forces the issue.<br />Clock drift exposes it.<br />Silent failures make it undeniable.</p>
<p>Once you internalize this, your design changes:</p>
<ul>
<li><p>Jobs become idempotent</p>
</li>
<li><p>Skipped runs are expected, not feared</p>
</li>
<li><p>“Every minute” is treated as best-effort</p>
</li>
<li><p>Observability moves into the application layer</p>
</li>
</ul>
<p>You stop trusting time blindly.</p>
<hr />
<h2 id="heading-the-defensive-mindset-cron-forces">The Defensive Mindset Cron Forces</h2>
<p>Cron is not malicious.<br />It’s not broken.<br />It’s just honest in a way that software engineers aren’t used to.</p>
<p>It will:</p>
<ul>
<li><p>Try to run things when time matches</p>
</li>
<li><p>Say nothing if it can’t</p>
</li>
<li><p>Move on without guilt</p>
</li>
</ul>
<p>If you design with that truth in mind, cron becomes predictable—even comforting.</p>
<p>If you don’t, cron will lie to you politely for months.</p>
<p>And the worst part is not that scheduled jobs don’t run.</p>
<p>It’s that <strong>nobody notices when they don’t</strong>.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-understanding-cron-from-first-principles-to-production"><strong>Introduction</strong></a></p>
</li>
<li><p><strong>Part 1:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-the-invisible-operating-system">Cron: The Invisible Operating System</a></p>
</li>
<li><p><strong>Part 2:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/anatomy-of-a-cron-job">Anatomy of a Cron Job</a></p>
</li>
<li><p><strong>Part 3:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-at-scale-patterns-and-anti-patterns">Cron at Scale: Patterns and Anti-Patterns</a></p>
</li>
<li><p><strong>Part 4:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-in-frameworks-from-theory-to-convention">Cron in Frameworks: From Theory to Convention</a></p>
</li>
<li><p><strong>Part 5:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/humhub-and-yii-design-intent-behind-the-cron-architecture">HumHub &amp; Yii: Design Intent Behind the Cron Architecture</a></p>
</li>
<li><p><strong>Part 6:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-production-setup-what-i-actually-built">A Real Production Setup: What I Actually Built</a></p>
</li>
<li><p><strong>Part 7:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-failure-modes-tradeoffs-and-lessons-learned">Failure Modes, Tradeoffs, and Lessons Learned</a></p>
</li>
<li><p><strong>Part 8:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/the-evolution-path-from-cron-to-orchestration">The Evolution Path: From Cron to Orchestration</a></p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p>→ ⏳ Cron Lies: When Scheduled Jobs Don’t Run</p>
</li>
<li><p>🔁 <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/idempotency-the-most-important-word-in-cron-youre-probably-ignoring">Idempotency: The Most Important Word in Cron</a></p>
</li>
<li><p>⚖️ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-vs-queue-vs-event-choosing-the-right-trigger">Cron vs Queue vs Event</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[The Evolution Path: From Cron to Orchestration]]></title><description><![CDATA[Cron has a strange reputation arc. Early in a system’s life, it feels empowering. Later, it’s blamed for things it never promised to do. Somewhere in the middle, teams either replace it wholesale—or quietly keep it while pretending they didn’t.
The t...]]></description><link>https://devpath-traveler.nguyenviettung.id.vn/the-evolution-path-from-cron-to-orchestration</link><guid isPermaLink="true">https://devpath-traveler.nguyenviettung.id.vn/the-evolution-path-from-cron-to-orchestration</guid><category><![CDATA[cronjob]]></category><category><![CDATA[System Design]]></category><category><![CDATA[BackendArchitecture ]]></category><category><![CDATA[Orchestration]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[Job Queue]]></category><category><![CDATA[cloud architecture]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Nguyễn Việt Tùng]]></dc:creator><pubDate>Sun, 25 Jan 2026 04:25:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769313459547/960a3072-fe26-4abb-8099-672b4273b6b5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cron has a strange reputation arc. Early in a system’s life, it feels empowering. Later, it’s blamed for things it never promised to do. Somewhere in the middle, teams either replace it wholesale—or quietly keep it while pretending they didn’t.</p>
<p>The truth is less dramatic. Cron doesn’t become obsolete; <strong>systems outgrow what they ask cron to do</strong>.</p>
<p>This final article is about placing cron correctly in the modern ecosystem: knowing when it’s enough, when it needs help, and how to evolve without ripping out the foundation you’ve already built.</p>
<hr />
<h3 id="heading-when-cron-is-enough">When Cron Is Enough</h3>
<p>Cron is enough when <strong>time-based intent is simple and execution is bounded</strong>.</p>
<p>In the system described earlier—built with <strong>Yii</strong> and <strong>HumHub</strong>—cron remained effective because it stayed within its competence:</p>
<ul>
<li><p>Schedules were coarse (minute-level, not second-level)</p>
</li>
<li><p>Jobs were idempotent</p>
</li>
<li><p>Heavy work was delegated</p>
</li>
<li><p>The number of scheduling entry points was small</p>
</li>
<li><p>Operational expectations were clear</p>
</li>
</ul>
<p>A setup like this is not fragile. It’s boring. And boring infrastructure ages well.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769313899769/07e92a7a-c447-48b9-b55c-21485b3dbb00.png" alt class="image--center mx-auto" /></p>
<p>If your system:</p>
<ul>
<li><p>Runs on a small number of hosts</p>
</li>
<li><p>Has predictable workloads</p>
</li>
<li><p>Can tolerate minute-level latency</p>
</li>
<li><p>Already separates scheduling from execution</p>
</li>
</ul>
<p>Then cron is not your bottleneck. Complexity elsewhere will hurt you first.</p>
<hr />
<h3 id="heading-when-cron-starts-to-feel-tight">When Cron Starts to Feel Tight</h3>
<p>Cron starts to feel constraining when <strong>execution outpaces scheduling</strong>.</p>
<p>Common signals include:</p>
<ul>
<li><p>Queues growing faster than they drain</p>
</li>
<li><p>Jobs overlapping more often than expected</p>
</li>
<li><p>Pressure to reduce latency below one minute</p>
</li>
<li><p>Multiple machines needing coordination</p>
</li>
<li><p>Manual reruns becoming operationally risky</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769313986292/f127cadd-74f0-4882-8dee-323e55693a7b.png" alt class="image--center mx-auto" /></p>
<p>None of these mean cron is “bad.” They mean cron is being asked to <strong>coordinate behavior across time, load, and topology</strong>—which is beyond its remit.</p>
<p>This is the point where you add layers, not replacements.</p>
<hr />
<h2 id="heading-adding-persistent-workers">Adding Persistent Workers</h2>
<p>The first evolution is usually not replacing cron, but <strong>removing its loop overhead</strong>.</p>
<p>In earlier examples, async jobs were processed like this:</p>
<pre><code class="lang-bash">* * * * * php yii queue/run
</code></pre>
<p>This works well, but it has a cost:</p>
<ul>
<li><p>PHP boots every minute</p>
</li>
<li><p>Configuration reloads every minute</p>
</li>
<li><p>Cold starts dominate short jobs</p>
</li>
</ul>
<p>With higher volume, the natural step is persistent workers.</p>
<p>Conceptually, the code doesn’t change:</p>
<pre><code class="lang-php">Yii::$app-&gt;queue-&gt;push(<span class="hljs-keyword">new</span> SendNotificationJob([
    <span class="hljs-string">'userId'</span> =&gt; $userId,
]));
</code></pre>
<p>What changes is <em>how workers run</em>:</p>
<ul>
<li><p>Long-lived processes</p>
</li>
<li><p>Managed by a supervisor</p>
</li>
<li><p>Restarted on failure</p>
</li>
<li><p>Scaled horizontally</p>
</li>
</ul>
<p>Cron still matters here. It often remains responsible for:</p>
<ul>
<li><p>Kicking off workers if they die</p>
</li>
<li><p>Scheduling low-frequency maintenance tasks</p>
</li>
<li><p>Acting as a safety net</p>
</li>
</ul>
<p>Cron steps back from execution, not from scheduling.</p>
<hr />
<h2 id="heading-introducing-distributed-queues">Introducing Distributed Queues</h2>
<p>The next pressure point is <strong>distribution</strong>.</p>
<p>As soon as multiple machines process jobs, new questions appear:</p>
<ul>
<li><p>Who owns which jobs?</p>
</li>
<li><p>How are retries coordinated?</p>
</li>
<li><p>How do you prevent double execution?</p>
</li>
</ul>
<p>Distributed queues answer these questions by centralizing state.</p>
<p>From the application’s point of view, nothing dramatic changes:</p>
<pre><code class="lang-php">Yii::$app-&gt;queue-&gt;push(<span class="hljs-keyword">new</span> RebuildIndexJob([
    <span class="hljs-string">'resourceId'</span> =&gt; $id,
]));
</code></pre>
<p>But operationally:</p>
<ul>
<li><p>Workers can live anywhere</p>
</li>
<li><p>Failures are isolated</p>
</li>
<li><p>Throughput scales independently of scheduling</p>
</li>
</ul>
<p>Cron’s role narrows again:</p>
<ul>
<li><p>Trigger periodic enqueues</p>
</li>
<li><p>Schedule reconciliation or cleanup</p>
</li>
<li><p>Act as a time-based initiator</p>
</li>
</ul>
<p>The system becomes event-heavy, but cron still provides temporal structure.</p>
<hr />
<h2 id="heading-cloud-schedulers-cron-with-a-different-accent">Cloud Schedulers: Cron, With a Different Accent</h2>
<p>Cloud schedulers often get framed as “replacing cron.” They don’t. They <strong>externalize it</strong>.</p>
<p>A cloud scheduler:</p>
<ul>
<li><p>Triggers execution based on time</p>
</li>
<li><p>Runs independently of your hosts</p>
</li>
<li><p>Integrates with managed services</p>
</li>
</ul>
<p>What changes is <em>where the clock lives</em>, not the concept.</p>
<p>Instead of:</p>
<pre><code class="lang-bash">* * * * * php yii cron/run
</code></pre>
<p>You get:</p>
<ul>
<li><p>A managed time trigger</p>
</li>
<li><p>Calling an endpoint</p>
</li>
<li><p>Or invoking a job runner</p>
</li>
</ul>
<p>The same design questions remain:</p>
<ul>
<li><p>What happens if execution is delayed?</p>
</li>
<li><p>How do you ensure idempotency?</p>
</li>
<li><p>Where does state live?</p>
</li>
</ul>
<p>Cloud schedulers reduce operational burden, not architectural responsibility.</p>
<hr />
<h2 id="heading-migration-strategies-that-dont-hurt">Migration Strategies That Don’t Hurt</h2>
<p>The biggest mistake teams make is trying to “modernize” cron in one leap.</p>
<p>The safer pattern is <strong>progressive delegation</strong>.</p>
<ol>
<li><p><strong>Start by isolating scheduling intent</strong><br /> If schedules live in crontab entries scattered across servers, centralize them in application code first.</p>
</li>
<li><p><strong>Delegate execution gradually</strong><br /> Move heavy logic behind queues or workers without changing cron triggers.</p>
</li>
<li><p><strong>Introduce persistence where it helps</strong><br /> Replace minute-based polling with long-lived workers only when startup cost dominates.</p>
</li>
<li><p><strong>Externalize time last</strong><br /> Move the clock out of the OS only when infrastructure maturity supports it.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769314594673/03745cff-0fe4-407f-a4fe-ead3c7d8059a.png" alt class="image--center mx-auto" /></p>
<p>At no point do you need to declare cron “deprecated.” You just ask it to do less.</p>
<hr />
<h2 id="heading-hybrid-models-cron-event-driven-systems">Hybrid Models: Cron + Event-Driven Systems</h2>
<p>In mature systems, cron rarely disappears. It becomes <strong>one trigger among many</strong>.</p>
<p>A common hybrid looks like this:</p>
<ul>
<li><p>Events trigger most work</p>
</li>
<li><p>Queues handle execution</p>
</li>
<li><p>Workers process continuously</p>
</li>
<li><p>Cron handles:</p>
<ul>
<li><p>Reconciliation</p>
</li>
<li><p>Cleanup</p>
</li>
<li><p>Periodic audits</p>
</li>
<li><p>Safety checks</p>
</li>
</ul>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769314788364/03f3eb00-3ddc-48b5-9875-bdf581d59722.png" alt class="image--center mx-auto" /></p>
<p>Cron becomes the system’s conscience—periodically asking, <em>“Is reality still consistent with our assumptions?”</em></p>
<p>That role doesn’t go away, no matter how event-driven you become.</p>
<hr />
<h2 id="heading-knowing-crons-proper-place">Knowing Cron’s Proper Place</h2>
<p>Cron’s proper place is not at the center of execution.<br />It’s at the boundary between <strong>time and intent</strong>.</p>
<p>It answers:</p>
<ul>
<li><em>When should something be considered?</em></li>
</ul>
<p>It does not answer:</p>
<ul>
<li><p><em>How should it scale?</em></p>
</li>
<li><p><em>How should it recover?</em></p>
</li>
<li><p><em>How should it coordinate across machines?</em></p>
</li>
</ul>
<p>The moment you expect cron to answer those questions, you’re setting yourself up for disappointment.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769315046008/662aff56-69ff-4a44-b3a9-4ad8450edf57.png" alt class="image--center mx-auto" /></p>
<p>But when you let cron do exactly what it’s good at—and no more—it remains one of the most stable pieces of infrastructure you’ll ever rely on.</p>
<hr />
<h2 id="heading-the-quiet-ending">The Quiet Ending</h2>
<p>There’s a reason cron survives every architectural fashion cycle. It encodes a truth that doesn’t age: <strong>time keeps passing, whether your system reacts or not</strong>.</p>
<p>Modern orchestration doesn’t replace that truth. It builds around it.</p>
<p>If readers walk away from this series with one instinct sharpened, let it be this:</p>
<blockquote>
<p>Don’t ask cron to be clever. Ask it to be punctual—and design everything else to handle the consequences.</p>
</blockquote>
<p>That’s not nostalgia.<br />That’s systems thinking.</p>
<hr />
<h2 id="heading-series-navigation">☰ Series Navigation</h2>
<h3 id="heading-core-series">Core Series</h3>
<ul>
<li><p><a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/introduction-to-understanding-cron-from-first-principles-to-production"><strong>Introduction</strong></a></p>
</li>
<li><p><strong>Part 1:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-the-invisible-operating-system">Cron: The Invisible Operating System</a></p>
</li>
<li><p><strong>Part 2:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/anatomy-of-a-cron-job">Anatomy of a Cron Job</a></p>
</li>
<li><p><strong>Part 3:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-at-scale-patterns-and-anti-patterns">Cron at Scale: Patterns and Anti-Patterns</a></p>
</li>
<li><p><strong>Part 4:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-in-frameworks-from-theory-to-convention">Cron in Frameworks: From Theory to Convention</a></p>
</li>
<li><p><strong>Part 5:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/humhub-and-yii-design-intent-behind-the-cron-architecture">HumHub &amp; Yii: Design Intent Behind the Cron Architecture</a></p>
</li>
<li><p><strong>Part 6:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-production-setup-what-i-actually-built">A Real Production Setup: What I Actually Built</a></p>
</li>
<li><p><strong>Part 7:</strong> <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-failure-modes-tradeoffs-and-lessons-learned">Failure Modes, Tradeoffs, and Lessons Learned</a></p>
</li>
<li><p>→ <strong>Part 8:</strong> The Evolution Path: From Cron to Orchestration</p>
</li>
</ul>
<h3 id="heading-optional-extras">Optional Extras</h3>
<ul>
<li><p>⏳ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-lies-when-scheduled-jobs-dont-run">Cron Lies: When Scheduled Jobs Don’t Run</a></p>
</li>
<li><p>🔁 <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/idempotency-the-most-important-word-in-cron-youre-probably-ignoring">Idempotency: The Most Important Word in Cron</a></p>
</li>
<li><p>⚖️ <a target="_blank" href="https://devpath-traveler.nguyenviettung.id.vn/cron-vs-queue-vs-event-choosing-the-right-trigger">Cron vs Queue vs Event</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>