<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tharindu Karunanayaka</title>
    <description>The latest articles on DEV Community by Tharindu Karunanayaka (@tharindukn).</description>
    <link>https://dev.to/tharindukn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3992923%2Fa315e3cb-3c11-4f81-af80-0ce08688a040.jpeg</url>
      <title>DEV Community: Tharindu Karunanayaka</title>
      <link>https://dev.to/tharindukn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tharindukn"/>
    <language>en</language>
    <item>
      <title>HTTP Finally Has a QUERY Method — and It Fixes a 30-Year-Old Compromise</title>
      <dc:creator>Tharindu Karunanayaka</dc:creator>
      <pubDate>Sun, 05 Jul 2026 03:41:57 +0000</pubDate>
      <link>https://dev.to/tharindukn/-http-finally-has-a-query-method-and-it-fixes-a-30-year-old-compromise-2b0d</link>
      <guid>https://dev.to/tharindukn/-http-finally-has-a-query-method-and-it-fixes-a-30-year-old-compromise-2b0d</guid>
      <description>&lt;p&gt;For as long as most of us have been building web APIs, there's been an awkward gap in HTTP's method vocabulary. You want to &lt;em&gt;ask&lt;/em&gt; a server a question — run a search, filter a dataset, evaluate a complex query — and get an answer back without changing anything. HTTP has never had a clean way to do that. As of &lt;strong&gt;RFC 10008&lt;/strong&gt;, published in &lt;strong&gt;June 2026&lt;/strong&gt;, it finally does: the &lt;strong&gt;QUERY&lt;/strong&gt; method.&lt;/p&gt;

&lt;p&gt;This post explains the problem QUERY solves, what makes it different from GET and POST, and the one genuinely tricky part — caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: GET and POST both fit badly
&lt;/h2&gt;

&lt;p&gt;When you need to send a query to a server, you've historically had two bad options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use GET.&lt;/strong&gt; GET is the natural fit semantically: it's &lt;em&gt;safe&lt;/em&gt; (it doesn't change server state) and &lt;em&gt;idempotent&lt;/em&gt; (making the same request twice has the same effect as making it once), and its responses are cacheable. The problem is that GET carries its parameters in the URL. That's fine for &lt;code&gt;?page=2&amp;amp;sort=name&lt;/code&gt;, but it falls apart the moment your query is large or structured. URLs have length limits (proxies, servers, and browsers all impose their own), you have to URL-encode everything, and expressing a nested JSON filter or a GraphQL-style query as query-string parameters ranges from ugly to impossible. Sending a request body with GET is technically undefined and widely ignored, so that door is closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use POST.&lt;/strong&gt; POST lets you put a rich, structured query in the request body with whatever media type you like — JSON, GraphQL, SQL-ish DSLs, protobuf, anything. That's why virtually every search and GraphQL endpoint uses &lt;code&gt;POST&lt;/code&gt; today. But POST is semantically &lt;em&gt;wrong&lt;/em&gt; for a read. POST is neither safe nor idempotent: to HTTP, caches, and intermediaries, a POST looks like it might create or change something. So responses generally aren't cached, clients won't automatically retry a failed POST (it might have side effects), and you lose all the machinery that makes reads on the web fast and resilient.&lt;/p&gt;

&lt;p&gt;So for decades we've been picking between "correct semantics but can't send real data" (GET) and "can send real data but wrong semantics" (POST). QUERY exists to end that compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What QUERY actually is
&lt;/h2&gt;

&lt;p&gt;QUERY is, in the words of the spec, a method that requests the target &lt;strong&gt;process the enclosed content&lt;/strong&gt; and respond with the result of that processing. In plainer terms: it's a &lt;strong&gt;request that carries a body like POST, but is declared safe and idempotent like GET.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three properties define it:&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;safe&lt;/strong&gt; — a QUERY does not alter the state of the resource it targets. It's a read, and clients, servers, and intermediaries can treat it as one.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;idempotent&lt;/strong&gt; — repeating the same QUERY has the same effect as sending it once. Because there's no partial state change to worry about, a QUERY can be safely retried or restarted automatically, for example after a dropped connection. This is the key thing POST can never offer.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;cacheable&lt;/strong&gt; — responses can be stored and reused, which brings the performance benefits of GET to requests that were previously stuck as uncacheable POSTs.&lt;/p&gt;

&lt;p&gt;The request body carries the query itself, and its media type describes what kind of query it is. The server processes that content and returns the result. This is exactly the shape that search endpoints, GraphQL, analytics APIs, and complex filtering have always wanted: a real body, plus honest read semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  QUERY vs GET vs POST at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;GET&lt;/th&gt;
&lt;th&gt;POST&lt;/th&gt;
&lt;th&gt;QUERY&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Carries a request body&lt;/td&gt;
&lt;td&gt;No (undefined)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe (no state change)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent (safe to retry)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cacheable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for large/structured queries&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;QUERY is essentially the cell that was always missing: the safe, idempotent, cacheable method that can still carry a body.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: caching is harder than it looks
&lt;/h2&gt;

&lt;p&gt;The headline feature — cacheable queries with a body — is also where the real engineering complexity lives, and it's worth being honest about it.&lt;/p&gt;

&lt;p&gt;With GET, the cache key is basically the URL. It's short, it's right there in the request line, and a cache can decide what to do before reading anything else. QUERY breaks that convenience. Because the meaningful part of the request now lives in the &lt;strong&gt;body&lt;/strong&gt;, a cache has to read the &lt;em&gt;entire&lt;/em&gt; request content to compute the cache key. That's fundamentally more work than keying on a URL.&lt;/p&gt;

&lt;p&gt;Worse, bodies aren't naturally canonical. Two JSON queries that are semantically identical — same fields, different whitespace or key ordering — are different byte sequences, and a naïve cache would treat them as different keys, tanking the hit rate. So effective QUERY caching depends on &lt;strong&gt;normalizing&lt;/strong&gt; the request body before keying on it, which pushes real complexity into caches and intermediaries. The spec also provides mechanisms for a server to point at a cacheable representation of the results (so that follow-up reads can behave more like ordinary GETs), but the underlying reality remains: caching a QUERY is materially more involved than caching a GET.&lt;/p&gt;

&lt;p&gt;None of this makes QUERY worse than the POST-based status quo — POST responses usually weren't cached at all. It just means the caching win isn't free; it arrives with new requirements for the infrastructure in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use it yet?
&lt;/h2&gt;

&lt;p&gt;QUERY is now a published standard (RFC 10008), which is the milestone that lets client libraries, servers, frameworks, proxies, and CDNs begin implementing it with confidence. But "standardized" and "universally deployed" are not the same thing. Real-world adoption depends on the whole chain — HTTP clients, server frameworks, load balancers, API gateways, and caches — understanding the method. Until that support is broad, a QUERY request may be rejected or mishandled by some intermediary that's never heard of it, and you'll still see POST used as the pragmatic fallback for a while.&lt;/p&gt;

&lt;p&gt;The sensible read for now: QUERY is the &lt;em&gt;correct&lt;/em&gt; answer to a problem the industry has worked around for thirty years, and it's finally a real standard rather than a proposal. If you build APIs where reads carry heavy or structured payloads — search, GraphQL, analytics, complex filtering — it's worth understanding now and adopting as your stack's support catches up. It won't replace GET for simple reads or POST for genuine writes; it fills the specific, long-standing gap between them.&lt;/p&gt;




&lt;p&gt;Sources: &lt;a href="https://www.rfc-editor.org/info/rfc10008/" rel="noopener noreferrer"&gt;RFC 10008: The HTTP QUERY Method (RFC Editor)&lt;/a&gt;, &lt;a href="https://datatracker.ietf.org/doc/html/rfc10008" rel="noopener noreferrer"&gt;RFC 10008 (IETF Datatracker)&lt;/a&gt;, &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/" rel="noopener noreferrer"&gt;draft-ietf-httpbis-safe-method-w-body (IETF Datatracker)&lt;/a&gt;, &lt;a href="https://www.developersdigest.tech/blog/rfc-10008-http-query-method" rel="noopener noreferrer"&gt;RFC 10008: The New HTTP QUERY Method Explained (Developers Digest)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>http</category>
      <category>query</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>AI-DLC: Giving Structure to AI-Assisted Development</title>
      <dc:creator>Tharindu Karunanayaka</dc:creator>
      <pubDate>Sun, 05 Jul 2026 03:30:12 +0000</pubDate>
      <link>https://dev.to/tharindukn/ai-dlc-giving-structure-to-ai-assisted-development-3k4f</link>
      <guid>https://dev.to/tharindukn/ai-dlc-giving-structure-to-ai-assisted-development-3k4f</guid>
      <description>&lt;p&gt;AI coding assistants are great at writing code and terrible at knowing &lt;em&gt;when&lt;/em&gt; to write it. Ask one to build a feature and it will happily jump straight to implementation, skipping the questions a good engineer asks first: what exactly are we building, why, what are the risks, and how should it be broken down? The result is fast output that often solves the wrong problem.&lt;/p&gt;

&lt;p&gt;AWS's &lt;strong&gt;AI-DLC (AI-Driven Development Life Cycle)&lt;/strong&gt; is an attempt to fix that gap. It's an open-source set of workflow rules — released by awslabs — that steer AI coding agents through a disciplined software development process instead of letting them freewheel. Importantly, it isn't a tool you install or a service you pay for. It's a methodology delivered as a bundle of markdown rules that your existing coding agent reads and follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: methodology over tooling
&lt;/h2&gt;

&lt;p&gt;One of AI-DLC's stated tenets is "methodology first." The whole thing ships as plain markdown rule files that you drop into whatever your agent already uses for project instructions — &lt;code&gt;CLAUDE.md&lt;/code&gt; for Claude Code, &lt;code&gt;.cursor/rules/&lt;/code&gt; for Cursor, &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; for GitHub Copilot, &lt;code&gt;.amazonq/rules/&lt;/code&gt; for Amazon Q, steering files for Kiro, and so on. There's nothing to run. The agent loads the rules and its behavior changes.&lt;/p&gt;

&lt;p&gt;This makes AI-DLC deliberately &lt;strong&gt;agnostic&lt;/strong&gt;. It doesn't tie you to a specific IDE, model, or vendor — any coding agent that supports project-level rules can use it. The philosophy is that a good development methodology should outlive any particular tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  A three-phase adaptive workflow
&lt;/h2&gt;

&lt;p&gt;At its heart, AI-DLC organizes work into three phases that mirror how thoughtful software actually gets built.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Inception phase&lt;/strong&gt; answers &lt;em&gt;what&lt;/em&gt; to build and &lt;em&gt;why&lt;/em&gt;. This is where the agent does requirements analysis, creates user stories when they're warranted, sketches the application design, breaks work into units that can be built in parallel, and assesses risk and complexity before a line of code is written.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Construction phase&lt;/strong&gt; answers &lt;em&gt;how&lt;/em&gt; to build it: detailed component design, actual code generation, build configuration, testing strategy, and quality validation.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Operations phase&lt;/strong&gt; covers deployment and monitoring — infrastructure automation, observability, and production-readiness checks. This phase is still marked as future/in-progress in the project, so it's more of a roadmap than a finished capability today.&lt;/p&gt;

&lt;p&gt;The word that matters most here is &lt;strong&gt;adaptive&lt;/strong&gt;. AI-DLC doesn't force every request through the full ceremony. It analyzes the complexity of what you're asking and only runs the stages that add value. A trivial change stays lightweight; a complex feature gets the full treatment with requirements analysis, design, and risk assessment. That's a sensible answer to the usual complaint about process — that it slows down simple work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does this mean for the traditional SDLC?
&lt;/h2&gt;

&lt;p&gt;The name is not an accident. AI-DLC is a deliberate play on the &lt;strong&gt;SDLC&lt;/strong&gt; — the Software Development Life Cycle that has organized how teams build software for decades: requirements, design, implementation, testing, deployment, maintenance. Those phases were written for human-only teams, where a person did the thinking at every stage and process existed mostly to coordinate people.&lt;/p&gt;

&lt;p&gt;AI-DLC's bet is that this assumption no longer holds. When an AI agent can generate design and code in minutes, the bottleneck shifts from &lt;em&gt;producing&lt;/em&gt; work to &lt;em&gt;deciding what to produce and validating it&lt;/em&gt;. So AI-DLC re-centers the life cycle around that new reality. Its three phases still map onto the old SDLC stages, but the roles are inverted: the agent does the heavy lifting of analysis, design, and implementation, while the human's job becomes steering and approving at each gate. The process isn't there to coordinate people anymore — it's there to keep humans in control of a machine that moves fast.&lt;/p&gt;

&lt;p&gt;Does that mean AI-DLC "replaces" the SDLC? In spirit, yes — it's positioning itself as the successor: the software life cycle rebuilt for the age of AI coding agents rather than bolted onto it. But it's worth being honest about the scope today. The Operations phase is still marked as future, the methodology explicitly keeps humans approving every critical decision, and AWS itself warns that generative AI still makes mistakes. So this is less "the SDLC is dead" and more "the SDLC is being rewritten" — the same fundamental stages, reorganized around who (or what) actually does the work. Teams that cling to a purely human, hand-offs-between-phases model will likely find it increasingly out of step with how software gets built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the human in the loop
&lt;/h2&gt;

&lt;p&gt;A recurring theme in AI-DLC is that the human approves and the agent proposes, never the other way around. The workflow is built around explicit checkpoints: you review each execution plan, approve each phase before it proceeds, and validate the artifacts the agent generates along the way. All of those artifacts land in an &lt;code&gt;aidlc-docs/&lt;/code&gt; directory so there's a paper trail of what was decided and why.&lt;/p&gt;

&lt;p&gt;Interestingly, AI-DLC pushes its structured questions into &lt;em&gt;files&lt;/em&gt; rather than chat. Instead of a back-and-forth conversation, the agent presents multiple-choice questions as documents you answer, which keeps decisions durable and reviewable rather than buried in a scrolling transcript. To start, you simply phrase your request beginning with "Using AI-DLC, ..." and the workflow activates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extensions: layering in your own rules
&lt;/h2&gt;

&lt;p&gt;Out of the box, the core workflow is general-purpose, but AI-DLC supports an &lt;strong&gt;extension system&lt;/strong&gt; for layering additional constraints on top. Extensions are grouped by category — the project ships with a &lt;code&gt;security/&lt;/code&gt; baseline and a &lt;code&gt;property-based testing/&lt;/code&gt; extension as examples.&lt;/p&gt;

&lt;p&gt;Each extension is a pair of files: a rules file and an opt-in prompt. When a workflow starts, AI-DLC scans for these opt-in prompts and asks you, during requirements analysis, whether to enable each one. Opt in, and those rules become &lt;em&gt;blocking constraints&lt;/em&gt; — at every stage the model must verify compliance before it's allowed to proceed. Extensions without an opt-in file are always enforced. This is how an organization can bake in security, compliance, or org-specific standards so the AI can't quietly skip them.&lt;/p&gt;

&lt;p&gt;Worth noting: AWS explicitly frames the bundled security extension as a &lt;em&gt;directional reference&lt;/em&gt;, not production-ready policy. You're expected to build, customize, and test your own rules before relying on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tenets that hold it together
&lt;/h2&gt;

&lt;p&gt;AI-DLC's design is anchored by a short set of principles: no duplication (one source of truth, other formats generated from it), methodology first (nothing to install), reproducibility (rules explicit enough that different models produce similar results), tool-agnosticism, and human-in-the-loop control. Taken together, they explain most of the design choices — why it's markdown instead of an app, why it works across agents, and why approval gates are everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it worth trying?
&lt;/h2&gt;

&lt;p&gt;If you already lean on an AI coding agent and find yourself frustrated that it rushes to code, over-engineers simple asks, or skips the thinking, AI-DLC is a low-commitment experiment. It's free, it's just files, it works with the tools you already have, and you can pull it back out just as easily. The honest caveats are that the operations phase isn't finished, the security extension is a starting point rather than a finished control, and — as AWS itself notes — generative AI still makes mistakes, so the review gates aren't optional theater. They're the point.&lt;/p&gt;

&lt;p&gt;For teams trying to get consistent, reviewable behavior out of AI assistants rather than one-off bursts of code, that's a genuinely useful framing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learn more
&lt;/h2&gt;

&lt;p&gt;The project is open source under the MIT-0 license and available on GitHub: &lt;strong&gt;&lt;a href="https://github.com/awslabs/aidlc-workflows" rel="noopener noreferrer"&gt;awslabs/aidlc-workflows&lt;/a&gt;&lt;/strong&gt;. The repository includes setup instructions for Kiro, Amazon Q, Cursor, Cline, Claude Code, and GitHub Copilot, along with links to AWS's AI-DLC methodology blog and method definition paper.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://github.com/awslabs/aidlc-workflows" rel="noopener noreferrer"&gt;awslabs/aidlc-workflows on GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>ai</category>
      <category>agents</category>
      <category>coding</category>
    </item>
    <item>
      <title>How Do You Log Someone Out of a Stateless System? JWT Invalidation on Logout</title>
      <dc:creator>Tharindu Karunanayaka</dc:creator>
      <pubDate>Sat, 04 Jul 2026 09:37:35 +0000</pubDate>
      <link>https://dev.to/tharindukn/how-do-you-log-someone-out-of-a-stateless-system-jwt-invalidation-on-logout-bbp</link>
      <guid>https://dev.to/tharindukn/how-do-you-log-someone-out-of-a-stateless-system-jwt-invalidation-on-logout-bbp</guid>
      <description>&lt;p&gt;JWTs are one of those technologies that feel wonderful right up until you hit your first "log me out" requirement. Then you discover the awkward truth: the very property that makes JWTs attractive — statelessness — is also what makes logout hard.&lt;/p&gt;

&lt;p&gt;This post walks through what JWTs actually are, why "invalidating" one is a design problem rather than a one-liner, and the practical methods available to revoke an access token on logout, along with the bottleneck each one introduces.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick refresher on JWTs
&lt;/h2&gt;

&lt;p&gt;A JSON Web Token (JWT) is a signed, self-contained token. It carries a JSON payload of claims — who the user is, when the token was issued, when it expires, and often a unique token id (&lt;code&gt;jti&lt;/code&gt;) — and a cryptographic signature over that payload. Because the token is signed with a secret (or a private key), any server holding the corresponding key can verify it is authentic and untampered &lt;em&gt;without calling a database&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That last part is the entire point. When a request arrives with a JWT, the server checks the signature and the expiry, reads the claims, and proceeds. No lookup, no shared session store, no round trip. This is what people mean when they call JWT auth &lt;strong&gt;stateless&lt;/strong&gt;: the server keeps no per-user session record. The token itself &lt;em&gt;is&lt;/em&gt; the session, and it's valid until it expires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access tokens and refresh tokens
&lt;/h2&gt;

&lt;p&gt;In practice you rarely use a single token. The common pattern splits responsibility across two:&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;access token&lt;/strong&gt; is the short-lived workhorse. It's sent on every API request and typically expires in minutes (5–15 is common). Because it's checked statelessly on every call, you want its lifetime short — if it leaks, the damage window is small.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;refresh token&lt;/strong&gt; is long-lived (days or weeks) and does one job: obtain new access tokens when the current one expires. It is not sent on every request — only to a dedicated token endpoint. This lets the access token stay short and stateless while the user avoids logging in every ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refresh token is easy — the access token is the problem
&lt;/h2&gt;

&lt;p&gt;Here's the key insight that shapes everything below: &lt;strong&gt;the refresh token is not the hard part of logout.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Refresh tokens are few, long-lived, and hit a single endpoint. Whoever issues them — your auth provider, or your own token endpoint — already stores them server-side. On logout you simply tell the provider to invalidate that refresh token, and it's done. The user can no longer mint new access tokens. One database write, one endpoint, no impact on your request hot path.&lt;/p&gt;

&lt;p&gt;The access token is where the difficulty actually lives. It's stateless by design: no server stored a copy, and its signature stays valid until the &lt;code&gt;exp&lt;/code&gt; claim passes. So even after you've killed the refresh token, the user's &lt;em&gt;current&lt;/em&gt; access token keeps working until it naturally expires. If you do nothing, a "logged out" user still has a working access token for its remaining lifetime.&lt;/p&gt;

&lt;p&gt;So the real question this post answers is narrow: &lt;strong&gt;how do we stop an already-issued access token from being accepted, without giving up too much of the statelessness we adopted JWTs for?&lt;/strong&gt; Every method below is a point on that tradeoff curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: Short expiry and just wait it out
&lt;/h2&gt;

&lt;p&gt;The simplest approach is to not revoke the access token at all. Keep it very short-lived and accept that logout means "invalidate the refresh token, delete the tokens client-side, and let the access token expire on its own."&lt;/p&gt;

&lt;p&gt;Because the refresh token is already dead, the user can't get a new access token — so the only exposure is the current one finishing out its few remaining minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottleneck:&lt;/strong&gt; it doesn't actually revoke anything. A leaked or stolen access token works until it expires, and there is no way to kill it early. This is perfectly fine for low-risk apps with genuinely short tokens, but unacceptable when you need immediate, guaranteed revocation of a specific token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Token blocklist (denylist)
&lt;/h2&gt;

&lt;p&gt;Keep a list of access tokens that have been explicitly revoked, identified by their &lt;code&gt;jti&lt;/code&gt;. On logout, add the token's &lt;code&gt;jti&lt;/code&gt; to the blocklist. On every request, look up the incoming token's &lt;code&gt;jti&lt;/code&gt; before honoring it — if it's on the list, reject it even though the signature is valid.&lt;/p&gt;

&lt;p&gt;The natural home for this is a &lt;strong&gt;cache with a TTL, such as Redis&lt;/strong&gt;. The crucial detail: you set each blocklist entry's TTL to match the token's own remaining validity. Once the token would have expired anyway, the entry evaporates on its own, so the blocklist never grows unbounded — it only ever holds tokens that are both revoked &lt;em&gt;and&lt;/em&gt; not yet naturally expired. Every request does a quick &lt;code&gt;jti&lt;/code&gt; lookup against that cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottleneck:&lt;/strong&gt; you've reintroduced a lookup on every request, which is exactly the statelessness you were trying to avoid. In practice it's a cheap in-memory hit, but it's now a hard dependency on the auth path — if the cache is down or slow, your authentication is down or slow. At scale it becomes shared infrastructure you must replicate, secure, and keep highly available. You've traded some statelessness for the ability to revoke instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: Token versioning / "valid-after" timestamp
&lt;/h2&gt;

&lt;p&gt;Store a single small value per user — a token version number or a &lt;code&gt;tokensValidAfter&lt;/code&gt; timestamp. Embed the version (or the token's issue time) in the JWT. On each request, compare the token against the user's current value; if the token predates it, reject it. Logging out is just bumping the version, which instantly invalidates every access token issued before the bump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottleneck:&lt;/strong&gt; you still need a lookup for the user's current version, so it isn't free — but it's a tiny per-user value rather than a per-token list, which makes it far cheaper to store and trivial to cache. The tradeoff is coarser granularity: bumping the version invalidates &lt;em&gt;all&lt;/em&gt; of that user's access tokens at once. That's exactly what you want for "log out everywhere," but if you need to revoke a single device while leaving others alive, you'd need per-device versioning, which grows the state again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between them: combine Method 1 and Method 2
&lt;/h2&gt;

&lt;p&gt;For most real systems, the best answer is not to pick one method but to &lt;strong&gt;combine short expiry (Method 1) with a &lt;code&gt;jti&lt;/code&gt; blocklist (Method 2)&lt;/strong&gt; — on top of invalidating the refresh token at the provider.&lt;/p&gt;

&lt;p&gt;Here's why the combination works so well. Method 1 alone can't revoke a specific token early; Method 2 alone would force you to keep a huge, long-lived blocklist if tokens lived for hours. Together they cancel out each other's weaknesses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short expiry keeps the blocklist tiny and self-cleaning.&lt;/strong&gt; Because access tokens only live a few minutes, any blocklist entry's TTL is also only a few minutes. The cache stays small, cheap, and fast no matter how many logouts happen — entries are added on logout and disappear moments later when the token would have expired anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blocklist closes the gap that short expiry leaves open.&lt;/strong&gt; Method 1 alone means a logged-out or stolen token keeps working for its full remaining lifetime. Adding the &lt;code&gt;jti&lt;/code&gt; blocklist lets you kill that specific token &lt;em&gt;immediately&lt;/em&gt;, so you get instant, guaranteed revocation when you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost stays bounded.&lt;/strong&gt;  be clear about what you're paying. Every single authenticated request now makes a network call to the cache to check the jti. That adds latency to each request — typically a small amount (often sub-millisecond to a few milliseconds on a Cache), but it's real, it's on the critical path, and it scales with your request volume. You're also now depending on the cache being available: if Cache is slow, every request slows down; if it's unreachable, you have to decide whether to fail open (accept tokens you can't verify against the blocklist) or fail closed (reject everyone), and neither is comfortable. And it's another piece of stateful infrastructure to run, replicate, monitor, and secure. The blocklist staying small thanks to short TTLs is genuinely helpful, but it doesn't remove these costs — it only keeps the memory footprint modest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put together, a typical logout flow looks like this: invalidate the refresh token at the provider (so no new access tokens can be minted), add the current access token's &lt;code&gt;jti&lt;/code&gt; to the Cache blocklist with a TTL equal to its remaining lifetime (so the existing token dies immediately), and clear the tokens on the client. Every incoming request then verifies the signature and expiry statelessly, plus one fast &lt;code&gt;jti&lt;/code&gt; lookup — and short token lifetimes guarantee that lookup table stays small.&lt;/p&gt;

&lt;p&gt;The recurring theme is unavoidable: &lt;strong&gt;every method that gives you real revocation power reintroduces some state.&lt;/strong&gt; JWTs let you choose &lt;em&gt;how much&lt;/em&gt; state and &lt;em&gt;where&lt;/em&gt; it sits. Combining short expiry with a small, TTL-bounded &lt;code&gt;jti&lt;/code&gt; blocklist is the sweet spot for most applications — it keeps the request path almost fully stateless while still letting you revoke an access token the instant a user logs out.&lt;/p&gt;

</description>
      <category>security</category>
      <category>jwt</category>
      <category>logout</category>
      <category>invalidation</category>
    </item>
    <item>
      <title>Social signing: log in, sign, never touch the key with AWS Nitro Enclaves</title>
      <dc:creator>Tharindu Karunanayaka</dc:creator>
      <pubDate>Sat, 04 Jul 2026 08:59:19 +0000</pubDate>
      <link>https://dev.to/tharindukn/social-signing-log-in-sign-never-touch-the-key-with-aws-nitro-enclaves-2if6</link>
      <guid>https://dev.to/tharindukn/social-signing-log-in-sign-never-touch-the-key-with-aws-nitro-enclaves-2if6</guid>
      <description>&lt;p&gt;Picture a product where users sign up with a normal social login, get a blockchain wallet without knowing it, and send transactions by clicking a button. No seed phrase, no browser extension, no scary "write down these 12 words or your money is gone forever" screen. To them it feels like any other app. Behind it sits a real on-chain account with a real private key.&lt;/p&gt;

&lt;p&gt;The whole thing lives or dies on one question: if a backend is creating and holding private keys for every user, what happens when someone breaks in? A database full of user private keys is about the worst thing you can leak. So the bar is simple to state and annoying to actually meet: &lt;em&gt;even the people running the service shouldn't be able to read the keys.&lt;/em&gt; Not the developers, not the on-call engineer at 2am, not an attacker who's popped a shell on the box, not whoever ends up with a copy of the database.&lt;/p&gt;

&lt;p&gt;The usual way teams get this experience is to buy it. You integrate a Wallet-as-a-Service provider — Privy, Magic, Web3Auth, Turnkey, etc abd let them handle key creation, custody, and signing behind their SDK. That's a perfectly reasonable choice, and for a lot of teams it's the right one. But it comes with trade-offs: a third party now holds (or co-controls) your users' keys, you're tied to their pricing and uptime, your security story is only as good as their black box, and there's usually a per-wallet or per-signature bill that grows with you. This post is about the other path — building the whole thing yourself, in your own AWS account, so no outside vendor ever touches a private key and the trust boundary is entirely yours.&lt;/p&gt;

&lt;p&gt;Doing that used to mean standing up and babysitting your own HSMs, which is exactly why most people reach for a WaaS in the first place. The shortcut is AWS Nitro Enclaves doing the sensitive work, with KMS acting as a bouncer that will only unlock a key for one specific, provable piece of code. You get HSM-grade key protection without running an HSM, on infrastructure you already have. I'll follow the user's journey — sign up, key gets made, log in, send funds — and bring in the security machinery at the points where it actually matters, rather than dumping it all up front.&lt;/p&gt;

&lt;p&gt;If you're comfortable with OAuth, EC2, IAM and a bit of Python you'll be fine. The examples are deliberately chain-agnostic: wherever you'd otherwise load a raw private key (ed25519, secp256k1, whatever your chain uses) into memory to sign a transaction, the same pattern applies. Slot in your chain's SDK where the placeholders are.&lt;/p&gt;

&lt;p&gt;Here's the shape of it before get into the weeds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Femc6s7esufrwzshttvra.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Femc6s7esufrwzshttvra.png" alt=" " width="800" height="1215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The one thing to hold onto: a plaintext private key only ever exists for a few milliseconds, and only ever in two places — inside a sealed enclave and inside a KMS hardware module. It never lands on the app servers, the database, the logs, or the wire. Not even when it's first created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing up, and letting the enclave make the key
&lt;/h2&gt;

&lt;p&gt;The front door is boring on purpose. User taps "sign up," an OpenID Connect flow runs through the identity provider (Cognito in front of the social providers works well here, but Auth0 or Google directly are the same idea), and back comes a JWT with the usual claims — &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; claim is the important one. It's a stable unique ID for the user, and it becomes the handle everything else hangs off of.&lt;/p&gt;

&lt;p&gt;Now, where do you generate the key? The obvious answer is a Lambda, and it works. But it has a nagging problem: for a few milliseconds the raw key sits in ordinary Lambda memory. Nobody's &lt;em&gt;supposed&lt;/em&gt; to be able to read it, but "supposed to" is doing a lot of work in that sentence. The stronger option is to generate the key inside the enclave too. Then the raw key is born inside the sealed box and gets encrypted before anything ever leaves. There's no window, however tiny, where it exists somewhere it could theoretically be read.&lt;/p&gt;

&lt;p&gt;The parent server just asks the enclave (over the local vsock channel — more on that soon) to make a wallet, and passes along some temporary AWS credentials so the enclave can reach KMS. This code runs &lt;em&gt;inside&lt;/em&gt; the enclave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;

&lt;span class="n"&gt;KMS_KEY_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:kms:us-east-1:123456789012:key/abcd-…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;kms_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The enclave has no network of its own; it reaches KMS through the
&lt;/span&gt;    &lt;span class="c1"&gt;# local vsock proxy the parent runs (same path used for decryption).
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret_access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;aws_session_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_wallet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Runs INSIDE the enclave. The plaintext private key never leaves this function.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_keypair&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# your chain's keygen (ed25519, secp256k1, …)
&lt;/span&gt;    &lt;span class="n"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;kms_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;KeyId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KMS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Plaintext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;# the raw private key material
&lt;/span&gt;    &lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CiphertextBlob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                         &lt;span class="c1"&gt;# public address, fine to hand back
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encrypted_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;          &lt;span class="c1"&gt;# only ciphertext leaves the enclave
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the enclave hands back the public address and the ciphertext, the parent's job is trivial: drop that ciphertext into Secrets Manager under the user's &lt;code&gt;sub&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;secrets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secretsmanager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aws_creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                  &lt;span class="c1"&gt;# who is this user?
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_enclave&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aws_creds&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wallet/user/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SecretString&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;           &lt;span class="c1"&gt;# { address, encrypted_key }
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                        &lt;span class="c1"&gt;# show the user their shiny new address
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnywyxio5f8ffrefjeoir.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnywyxio5f8ffrefjeoir.webp" alt=" " width="799" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the key actually lives
&lt;/h2&gt;

&lt;p&gt;The encrypted key goes into AWS Secrets Manager, one secret per user, named &lt;code&gt;wallet/user/&amp;lt;sub&amp;gt;&lt;/code&gt;. What's stored there is already useless on its own — it's envelope ciphertext that can't be unwrapped without both KMS and the enclave, which is the whole point of the next section. So even if someone dumped every secret in the account, they'd have a pile of garbage.&lt;/p&gt;

&lt;p&gt;Secrets Manager is a good fit here for a few reasons beyond just holding bytes. It re-encrypts everything at rest with its own KMS key, so the key ends up wrapped twice: once by the master key inside the enclave, then again by Secrets Manager. It gives you per-secret IAM, CloudTrail on every read, and versioning, all of which are nice to have when the thing you're storing is somebody's wallet. Scope the parent's role so it can only touch &lt;code&gt;wallet/user/*&lt;/code&gt; and nothing else in the account.&lt;/p&gt;

&lt;p&gt;One detail on cost and key management, since it comes up: use a &lt;em&gt;single&lt;/em&gt; KMS master key to encrypt every user's private key. A private key is nowhere near KMS's 4 KB encryption limit, so there's no reason to mint a KMS key per user, and KMS bills per master key, so per-user keys would be financially silly. The per-user isolation that matters doesn't come from having millions of master keys anyway — it comes from each ciphertext being distinct and from the decrypt being gated. Which, finally, brings us to the interesting part.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftsih5bxqf0bvjzw1iz7d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftsih5bxqf0bvjzw1iz7d.png" alt=" " width="798" height="82"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending a transaction by logging in
&lt;/h2&gt;

&lt;p&gt;This is the payoff and also where all the security machinery earns its keep. A returning user logs in, gets a fresh JWT, asks to send some funds, and the system signs the transaction with their key — without that key ever being visible to the servers. To believe that last clause you need to understand three things, so here they are roughly in the order you'd hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sealed box
&lt;/h3&gt;

&lt;p&gt;A Nitro Enclave is a stripped-down virtual machine carved out of a normal EC2 instance. No disk, no network card, no SSH, and — this is the part that matters — no way for anyone to read its memory, including root on the parent instance. The only channel in or out is a local socket called vsock, addressed by a context ID (think of it like an IP) and a port. Only the key-handling code runs in there: creating wallets and signing. Everything else — the web server, JWT checks, Secrets Manager calls — runs on the parent, outside the box, which you should assume could get compromised.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwotetsfpumdsd5jg4jn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwotetsfpumdsd5jg4jn.png" alt=" " width="800" height="1253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  PCR0, or how KMS knows it's really your code
&lt;/h3&gt;

&lt;p&gt;For KMS to hand a key only to your signing code, it needs some way to recognize that code. That mechanism is PCR0.&lt;/p&gt;

&lt;p&gt;PCR is short for Platform Configuration Register. When an enclave boots, the Nitro hypervisor measures the image as it loads it — hashing the contents into a set of registers that the enclave itself can't write to. If you've ever poked at a TPM and secure boot, it's the same idea. Only the hypervisor sets these values, at boot, which is exactly why you can trust them. There are a few of them:&lt;/p&gt;

&lt;p&gt;PCR0 is a SHA-384 hash of the entire enclave image, kernel and app and all. That's the one you'll usually use. PCR1 covers just the kernel and bootstrap, PCR2 just the app without the kernel, and PCR8 is the certificate you signed the image with, if you signed it (handy if you'd rather pin "anything the build pipeline signed" than one exact hash).&lt;/p&gt;

&lt;p&gt;The thing to internalize about PCR0 is that it's a content hash, so it's completely deterministic and completely unforgiving. Rebuild the exact same image and you get the exact same PCR0. Change one line, bump a dependency, nudge the base image, and it's a totally different value. You see it printed when you build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nitro-cli build-enclave &lt;span class="nt"&gt;--docker-uri&lt;/span&gt; signing-server:latest &lt;span class="nt"&gt;--output-file&lt;/span&gt; signing_server.eif
&lt;span class="c"&gt;# Measurements:&lt;/span&gt;
&lt;span class="c"&gt;#   "PCR0": "abacc679…cacd9fc0373ecd78c34c3e4cbf78ea9b5b0452a18"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string is the fingerprint of your exact code, and it's what KMS checks before it will decrypt anything. Two things trip people up here, so consider yourself warned. First, because PCR0 changes on every code change, shipping a new enclave build means updating the KMS policy — allow both the old and new value during a rollout so you don't lock yourself out mid-deploy (signing images and pinning PCR8 is the cleaner long-term answer). Second, if you run with &lt;code&gt;--debug-mode&lt;/code&gt; to see console output, attestation is disabled and all the PCRs go to zero. Great for local testing, a disaster if you point it at real keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attestation, and the part everyone gets wrong
&lt;/h3&gt;

&lt;p&gt;When the enclave asks KMS to decrypt, it doesn't send a plain API call. The Nitro SDK wraps the request in a signed attestation document that carries the PCR values and a one-time public key the enclave just generated. KMS checks that the document genuinely came from Nitro hardware and that PCR0 matches the condition in the key policy.&lt;/p&gt;

&lt;p&gt;(Worth noting the asymmetry with sign-up: encrypting the key back during account creation needed no attestation at all — anyone with the right IAM can encrypt. Decrypting is the dangerous direction, so that's the one that's locked down.)&lt;/p&gt;

&lt;p&gt;Here's the bit that's easy to get wrong, and almost everyone does at first: the decryption happens in &lt;em&gt;two&lt;/em&gt; places, not one.&lt;/p&gt;

&lt;p&gt;The real decryption happens inside KMS, not in the enclave. The master key never leaves the hardware module; KMS unwraps the ciphertext into the raw key on its side. But it obviously can't just send that plaintext back over the wire, since the reply travels through the untrusted parent to get to the enclave. So instead KMS re-encrypts the plaintext using that one-time public key from the attestation document and returns &lt;em&gt;that&lt;/em&gt; (the &lt;code&gt;CiphertextForRecipient&lt;/code&gt; field). Then a second decryption happens locally, inside the enclave, using the matching one-time private key that never left it.&lt;/p&gt;

&lt;p&gt;You don't hand-roll any of this. The &lt;code&gt;kmstool_enclave_cli&lt;/code&gt; binary that AWS ships does both halves — it builds the attested request (that's decryption #1, in KMS) and does the local unseal (decryption #2, in the enclave), and just hands you the plaintext.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0ro147b5jklyw78ez04.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0ro147b5jklyw78ez04.png" alt=" " width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The policy condition doing the enforcing is one line, &lt;code&gt;kms:RecipientAttestation:ImageSha384&lt;/code&gt;, which reads as "only an enclave whose PCR0 equals this may decrypt":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow decrypt only from our enclave"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;ACCOUNT_ID&amp;gt;:role/ec2-instance-role"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kms:Decrypt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"StringEqualsIgnoreCase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kms:RecipientAttestation:ImageSha384"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;YOUR_PCR0_VALUE&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So stealing the instance credentials gets you nowhere. Dumping every secret gets you nowhere. To decrypt a key you'd have to &lt;em&gt;be the exact code, running inside a genuine enclave&lt;/em&gt; — and even that wouldn't help you, for reasons I'll come back to at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring it together
&lt;/h3&gt;

&lt;p&gt;The full send path: verify the login JWT, pull the user's ciphertext out of Secrets Manager by &lt;code&gt;sub&lt;/code&gt;, fill in the public chain data the transaction needs (nonce, fee, gas, chain ID — whatever your chain uses), then push the ciphertext and the unsigned transaction into the enclave to sign.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomlh57f30bg610e5dv3j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomlh57f30bg610e5dv3j.png" alt=" " width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Verifying the login token on the parent is a few lines of PyJWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PyJWKClient&lt;/span&gt;

&lt;span class="n"&gt;jwks_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PyJWKClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://YOUR_IDP_DOMAIN/.well-known/jwks.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-identifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://YOUR_IDP_DOMAIN/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;                                  &lt;span class="c1"&gt;# ["sub"] == the user's Secrets Manager key
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on stepping up to MFA for bigger transfers.&lt;/strong&gt; The login JWT tells you who the user is, but for a large payment you want fresh proof that the actual human is behind &lt;em&gt;this particular transaction&lt;/em&gt;, not just that they logged in an hour ago. The pattern for that is step-up authentication. The parent runs some business logic first — over some value threshold, say, or past a daily cumulative limit, or a destination the account has never sent to — and if the transaction trips that rule it refuses to sign and sends back a challenge instead. The app then prompts for MFA (a TOTP code, a passkey, a push), and on success the IdP mints a &lt;em&gt;second&lt;/em&gt; JWT that's scoped to this one transaction: bound to the exact amount and destination, with an expiry measured in a minute or two so it can't be replayed on a different or later transfer. The parent checks both tokens — the session one for identity, the step-up one for this specific transaction — before anything reaches the enclave.&lt;/p&gt;


&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_step_up_if_needed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_up_token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                           &lt;span class="c1"&gt;# small tx: session JWT is enough
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;step_up_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;StepUpRequired&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                    &lt;span class="c1"&gt;# tell the app to prompt for MFA
&lt;/span&gt;    &lt;span class="n"&gt;mfa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_up_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# same signature/claims checks
&lt;/span&gt;    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mfa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;session_claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="c1"&gt;# bind it to THIS user + THIS transfer
&lt;/span&gt;            &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;mfa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
            &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;mfa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;
            &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mfa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mfa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])):&lt;/span&gt;   &lt;span class="c1"&gt;# amr = auth methods actually used
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Unauthorized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The nice thing is that it's a completely separate lock that slots in &lt;em&gt;before&lt;/em&gt; the key machinery, and it doesn't touch any of it. The enclave, the attestation, the KMS policy — all unchanged. You've just made the "should this transfer happen at all" decision stricter for the transactions that deserve the extra friction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The parent side, then, authenticates, loads the ciphertext, fills in the public chain data, and relays. It never touches a plaintext key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;secrets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secretsmanager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChainClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-node.example:PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# your chain's RPC client
&lt;/span&gt;&lt;span class="n"&gt;ENCLAVE_CID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ENCLAVE_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_enclave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_VSOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_STREAM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ENCLAVE_CID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ENCLAVE_PORT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aws_creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                   &lt;span class="c1"&gt;# 401 if invalid
&lt;/span&gt;    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_secret_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SecretId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wallet/user/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SecretString&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Build the unsigned tx and fill in public chain data (nonce, fee, gas, chain id…).
&lt;/span&gt;    &lt;span class="n"&gt;unsigned_tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_addr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;unsigned_tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fill_chain_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unsigned_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# public data, no key needed
&lt;/span&gt;
    &lt;span class="n"&gt;signed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_enclave&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sign&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;unsigned_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encrypted_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encrypted_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aws_creds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                &lt;span class="c1"&gt;# temp instance creds
&lt;/span&gt;    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signed_tx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                 &lt;span class="c1"&gt;# submit; needs no key
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does the enclave need AWS credentials handed to it? Because it has no network of its own. It borrows the parent's temporary instance credentials, tunneled through the same vsock proxy, to reach KMS. On their own those credentials are useless for this — without a valid attestation document, KMS just refuses to decrypt.&lt;/p&gt;

&lt;p&gt;And the enclave itself, which is a single small server handling both the &lt;code&gt;create&lt;/code&gt; from sign-up and the &lt;code&gt;sign&lt;/code&gt; here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="n"&gt;VSOCK_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;kms_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ciphertext_hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;kmstool_enclave_cli does decryption #1 (KMS) and #2 (local unseal).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/kmstool_enclave_cli&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--proxy-port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--aws-access-key-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--aws-secret-access-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret_access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--aws-session-token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--ciphertext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ciphertext_hex&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# "PLAINTEXT: &amp;lt;base64&amp;gt;"
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# the raw private key
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;create_wallet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;          &lt;span class="c1"&gt;# from sign-up
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sign&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;private_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;kms_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encrypted_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;signed_tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sign_transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# your chain's signer
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signed_tx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signed_tx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;private_key&lt;/span&gt;                              &lt;span class="c1"&gt;# don't let plaintext outlive the request
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_VSOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_STREAM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VMADDR_CID_ANY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VSOCK_PORT&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire trust boundary, and it's small enough to read in one sitting. The same sealed box both mints keys and signs with them, and in either case the plaintext key only exists inside the enclave — created in &lt;code&gt;create_wallet&lt;/code&gt; or briefly revived in &lt;code&gt;kms_decrypt&lt;/code&gt;, used once, and dropped before any reply crosses back over vsock. (&lt;code&gt;generate_keypair&lt;/code&gt; and &lt;code&gt;sign_transaction&lt;/code&gt; are the only two chain-specific pieces; everything else is identical no matter which blockchain you're on.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Building and running it
&lt;/h2&gt;

&lt;p&gt;The enclave app is a Docker image that you convert into an enclave image file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; amazonlinux:2&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;yum &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; python3 gcc python3-devel &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pip3 &lt;span class="nb"&gt;install &lt;/span&gt;boto3 &amp;lt;your-chain-sdk&amp;gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; kmstool_enclave_cli /app/kmstool_enclave_cli&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; libnsm.so /app/libnsm.so&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.py /app/server.py&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python3", "/app/server.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nitro-cli build-enclave &lt;span class="nt"&gt;--docker-uri&lt;/span&gt; signing-server:latest &lt;span class="nt"&gt;--output-file&lt;/span&gt; signing_server.eif
&lt;span class="c"&gt;# ^ prints PCR0 → paste it into the KMS key policy&lt;/span&gt;
nitro-cli run-enclave &lt;span class="nt"&gt;--cpu-count&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory&lt;/span&gt; 3806 &lt;span class="se"&gt;\&lt;/span&gt;
                      &lt;span class="nt"&gt;--eif-path&lt;/span&gt; signing_server.eif &lt;span class="nt"&gt;--enclave-cid&lt;/span&gt; 16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: enclaves aren't managed by Docker or systemd, so nothing restarts them for you if they die. A tiny watchdog under systemd that polls &lt;code&gt;nitro-cli describe-enclaves&lt;/code&gt; and relaunches the enclave when it's gone covers this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Back at the start, the choice looked like "buy a Wallet-as-a-Service and hand off custody" or "run your own HSMs and hate your life." This is the third door. You get HSM-grade key protection out of infrastructure you already run, you own the entire trust boundary end to end, and the only two pieces that are chain-specific are &lt;code&gt;generate_keypair&lt;/code&gt; and &lt;code&gt;sign_transaction&lt;/code&gt; — everything else is the same whether you're on Ethereum, Solana, Bitcoin, or something that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;It's not a small amount of moving parts, and the attestation flow takes a beat to click. But once it does, the mental model is clean: social login decides &lt;em&gt;who&lt;/em&gt; can act, the JWT and step-up MFA decide &lt;em&gt;what&lt;/em&gt; they can do, PCR0 decides &lt;em&gt;which code&lt;/em&gt; is allowed to touch a key, and the enclave plus KMS make sure the plaintext only ever exists somewhere nobody — including you — can reach into. That's a wallet you can put in front of ordinary users and still describe honestly: we made your key, we help you spend it, and we can't read it.&lt;/p&gt;

&lt;p&gt;If you take one thing away, let it be that "non-custodial-ish, self-hosted, no vendor holding the keys" is not a moonshot anymore. It's a weekend spike on top of a reference implementation. So go build it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More info&lt;/strong&gt; — the full working reference implementation (VPC, KMS policy, enclave build, CDK) is open source: &lt;a href="https://github.com/aws-samples/aws-nitro-enclave-blockchain-wallet" rel="noopener noreferrer"&gt;aws-samples/aws-nitro-enclave-blockchain-wallet&lt;/a&gt;. Clone it, deploy it, and read the parts this post glossed over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next steps&lt;/strong&gt; — two directions worth exploring once the single-enclave version is working:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Remove the single point of trust with MPC.&lt;/strong&gt; Right now one enclave can reconstruct a whole key. Split it so no single enclave (or single AWS account) ever holds the complete key, and a signature needs several parties to cooperate: &lt;a href="https://aws.amazon.com/blogs/web3/build-secure-multi-party-computation-mpc-wallets-using-aws-nitro-enclaves/" rel="noopener noreferrer"&gt;Build secure multi-party computation (MPC) wallets using AWS Nitro Enclaves&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it production-grade.&lt;/strong&gt; Add key backup and recovery (what happens if a user loses access?), multi-AZ high availability for the enclave and monitoring/alerting on failed attestations.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>blockchain</category>
      <category>wallet</category>
    </item>
  </channel>
</rss>
