<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Session Management | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/session-management/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/session-management/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Mon, 19 Oct 2026 06:00:12 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Session Management | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/session-management/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Building a Secure Patient Document Portal on S3 and Cognito</title>
		<link>https://john-nessime.com/blog/cloud-computing/secure-patient-document-portal-s3-cognito/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 19 Oct 2026 06:00:12 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[Bucket Policy]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Cognito]]></category>
		<category><![CDATA[Encryption]]></category>
		<category><![CDATA[ePHI]]></category>
		<category><![CDATA[Healthcare Cloud]]></category>
		<category><![CDATA[HIPAA]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Identity Pools]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[PHI]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[Session Management]]></category>
		<category><![CDATA[Token Revocation]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=457</guid>

					<description><![CDATA[<p>Cognito for sign-in, S3 for the files, and a demo that passes on the first try. The problems start after that. Prefix isolation, presigned URL lifetimes, KMS as a second gate, CloudTrail data events, and token revocation that actually ends a session.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/secure-patient-document-portal-s3-cognito/">Building a Secure Patient Document Portal on S3 and Cognito</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The request usually arrives sideways. A clinic manager says something like: &#8220;we&#8217;re emailing scans as PDF attachments and our insurer flagged it, can you build us something?&#8221; Nobody says the words &#8220;patient portal.&#8221; They say &#8220;somewhere for patients to pick up their files.&#8221;</p>



<p class="wp-block-paragraph">So you sketch the obvious thing. Amazon Cognito for sign-in, Amazon S3 for the documents, a small front end in between. It&#8217;s three services and a weekend, and it works on the first try, which is exactly the problem. A <strong>secure patient document portal</strong> built this way passes every test you think to run, because the tests you think to run are all &#8220;can Alice download Alice&#8217;s file?&#8221; The one that matters is &#8220;what can Alice see about Bob?&#8221; and that one has an answer you won&#8217;t like.</p>



<p class="wp-block-paragraph">This post covers the parts that go wrong after the demo. Where to put the authorization boundary, how to scope the S3 policy so one patient can&#8217;t enumerate another, why your download links expire earlier than you configured, what encryption actually gates, and what your audit trail looks like on the day someone asks for it. Everything here is mechanism, not a compliance checklist. Getting a Business Associate Agreement in place is a separate conversation with your lawyer and your AWS account team.</p>



<h2 class="wp-block-heading">The failure that never pages you</h2>



<p class="wp-block-paragraph">Here&#8217;s the shape of it. You write an IAM policy that lets a signed-in user read objects under their own prefix. Something like <code>arn:aws:s3:::clinic-docs/patients/${cognito-identity.amazonaws.com:sub}/*</code>. You test it. Alice gets her file. Alice cannot get Bob&#8217;s file. Ship it.</p>



<p class="wp-block-paragraph">Then, because the front end needs to render a file list, someone adds <code>s3:ListBucket</code>. And <code>s3:ListBucket</code> is not an object-level action. It applies to the bucket ARN, not the object ARN, so the <code>${cognito-identity.amazonaws.com:sub}</code> substitution in your resource string does nothing for it. Unless you add a separate condition on the request, a signed-in patient can list every key in the bucket.</p>



<p class="wp-block-paragraph">They still can&#8217;t download anything. Every <code>GetObject</code> outside their prefix returns a denial. But they can read the key names. And key names in a clinic bucket are rarely neutral. <code>patients/8f2a.../oncology-referral-2024.pdf</code> tells you something about a person even if the bytes stay locked.</p>



<p class="wp-block-paragraph">Nothing alerts on this. No error appears in any log you&#8217;re watching, because nothing errored. The listing succeeded. That&#8217;s the failure mode worth building around: in a document portal, the metadata leak is the one that doesn&#8217;t announce itself.</p>



<p class="wp-block-paragraph">The fix is a condition key on the list action, not a resource string:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListOwnPrefixOnly",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::clinic-docs",
      "Condition": {
        "StringLike": {
          "s3:prefix": [
            "patients/${cognito-identity.amazonaws.com:sub}/*"
          ]
        }
      }
    },
    {
      "Sid": "ReadOwnObjectsOnly",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::clinic-docs/patients/${cognito-identity.amazonaws.com:sub}/*"
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Two statements, two different jobs. The first constrains what the caller may ask the bucket to enumerate. The second constrains what bytes the caller may pull. Write only the second one and you&#8217;ve secured the download path while leaving the discovery path wide open.</p>



<p class="wp-block-paragraph">One trap in that variable. <code>${cognito-identity.amazonaws.com:sub}</code> resolves to the identity ID from the Cognito <em>identity pool</em>, not the <code>sub</code> claim from the user pool. They are different values for the same human. If you name your prefixes with the user pool sub because that&#8217;s what your application code has handy, the policy will match nothing and every request will be denied, which at least fails loudly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Decide where the authorization boundary lives</h2>



<p class="wp-block-paragraph">There are two honest architectures here and the choice shapes everything downstream. Pick deliberately.</p>



<h3 class="wp-block-heading">Pattern A: browser holds AWS credentials</h3>



<p class="wp-block-paragraph">The user signs in to a Cognito user pool. The application exchanges that token with a Cognito identity pool for temporary AWS credentials, and the browser calls S3 directly using the AWS SDK.</p>



<p class="wp-block-paragraph">It&#8217;s less code and there&#8217;s no server to run. The authorization boundary is entirely the IAM role policy attached to the identity pool. That policy is the whole security model, so it had better be right, and it&#8217;s harder to reason about than application code because the enforcement happens in a service you can&#8217;t step through with a debugger.</p>



<p class="wp-block-paragraph">The real limitation is expressiveness. IAM policy conditions do not know that Bob&#8217;s referral was superseded, that Alice revoked consent last Tuesday, or that a document should only be visible after a clinician releases it. Anything with business logic in it has to be encoded as prefix structure or object tags, and that gets brittle fast.</p>



<h3 class="wp-block-heading">Pattern B: backend brokers every object</h3>



<p class="wp-block-paragraph">The browser never touches AWS credentials. It sends the Cognito access token to your own API. The API validates the token, checks its own records for who may see what, and returns a presigned URL for that one object. S3 is reachable only by the API&#8217;s execution role.</p>



<p class="wp-block-paragraph">You now have a place to put logic, a place to log intent alongside access, and a single choke point to rate limit. The cost is that you own a service, its uptime, and its dependencies.</p>



<p class="wp-block-paragraph">For a small clinic build, this is the one I reach for first. Not because Pattern A is insecure, but because the questions clients ask in month three (&#8220;can we hide results until the doctor reviews them?&#8221;) are answerable in Pattern B and require a re-architecture in Pattern A. The identity-pool approach earns its keep when access really is a pure function of who you are, which in healthcare it rarely stays.</p>



<p class="wp-block-paragraph">If you do want attribute-driven access without a backend, Cognito identity pools can map claims from the token onto principal tags on the temporary session, which you then reference in policy conditions. It works, and it is the right tool for coarse distinctions like patient versus staff. It is not the right tool for per-document consent state.</p>



<h2 class="wp-block-heading">Your download links expire before you think they do</h2>



<p class="wp-block-paragraph">This one generates support tickets on week two. You set a presigned URL to last a few hours. Patients report links dying in under one. Nothing in your code changed.</p>



<p class="wp-block-paragraph">A presigned URL carries a signature made with whatever credentials signed it, and it cannot outlive those credentials. AWS documents the ceilings clearly: with long-lived IAM user keys and Signature Version 4 the maximum is seven days; from the S3 console the range is one minute to twelve hours; with temporary credentials from an assumed role, the URL dies when the role session does, regardless of the expiry you asked for. A default <code>AssumeRole</code> session is one hour.</p>



<p class="wp-block-paragraph">Your Lambda or container signing those URLs is using temporary credentials. So the effective lifetime is the shorter of your requested expiry and the remaining time on that role session, and the remaining time depends on when the runtime last refreshed. That&#8217;s the source of the flapping. You can watch it yourself:</p>



<pre class="wp-block-code"><code># Sign a URL and inspect what was actually encoded into it.
aws s3 presign s3://clinic-docs/patients/example/scan.pdf --expires-in 3600

# The returned URL contains X-Amz-Date, X-Amz-Expires and X-Amz-Credential.
# X-Amz-Expires is the window you asked for. If X-Amz-Credential shows an
# ASIA... key id, you signed with temporary credentials and the real ceiling
# is the session, not the window.</code></pre>



<p class="wp-block-paragraph">The honest resolution is to stop fighting it. Sign short, on demand, when the user clicks. Fifteen minutes is generous for a download the patient is standing in front of. Long links are not a convenience feature in this context anyway, because a presigned URL is a bearer credential: whoever holds the string can fetch the object, signed in or not. Forwarding the email forwards the access. If your portal mails links, you have moved the authorization boundary into someone&#8217;s inbox.</p>



<p class="wp-block-paragraph">Mail a notification that says a document is ready and link to the portal login. Never mail the signed URL itself.</p>



<h2 class="wp-block-heading">Encryption that actually gates something</h2>



<p class="wp-block-paragraph">New S3 objects are encrypted at rest by default with S3-managed keys. That satisfies the sentence &#8220;data is encrypted at rest&#8221; and gates precisely nothing, because anyone with <code>s3:GetObject</code> gets plaintext back. It is a storage property, not an access control.</p>



<p class="wp-block-paragraph">Server-side encryption with an AWS KMS customer managed key is different in kind. Reading an object now requires two independent grants: the S3 permission and <code>kms:Decrypt</code> on the key. Those live in different policies and can be owned by different people. A bucket policy mistake no longer hands over plaintext on its own, and revoking the key grant cuts off reads without touching a single S3 permission.</p>



<p class="wp-block-paragraph">You also get a second audit stream. Every decrypt is a KMS API call, recorded separately from the S3 call, which is useful precisely because an attacker who tampers with one trail probably didn&#8217;t think about the other.</p>



<p class="wp-block-paragraph">The costs are real and worth stating. KMS charges per request, so a portal that fetches many small objects will notice. S3 Bucket Keys reduce that request volume substantially by caching a bucket-level key, at the price of coarser detail in the KMS audit records. For a document portal with modest traffic and high sensitivity, I&#8217;d take the per-request cost and keep the granularity. For a thumbnail-heavy application, the opposite.</p>



<h2 class="wp-block-heading">The audit trail you don&#8217;t have yet</h2>



<p class="wp-block-paragraph">Assume someone will eventually ask: who downloaded this specific file, and when? If you have not explicitly turned something on, the answer is that you don&#8217;t know.</p>



<p class="wp-block-paragraph">CloudTrail is on for management events by default, and management events are control-plane operations: creating the bucket, changing its policy, attaching a role. Object-level reads and writes are <em>data events</em>, and data events are off by default because of volume and cost. <code>GetObject</code> on a patient scan is a data event. Unless you configured a trail with data events for that bucket, no record of the download exists.</p>



<pre class="wp-block-code"><code># Log S3 object-level activity for one bucket on an existing trail.
# Advanced event selectors let you scope to a single bucket prefix rather
# than every object in the account.
aws cloudtrail put-event-selectors 
  --trail-name clinic-audit-trail 
  --advanced-event-selectors '[
    {
      "Name": "PHI bucket object access",
      "FieldSelectors": [
        {"Field": "eventCategory", "Equals": ["Data"]},
        {"Field": "resources.type", "Equals": ["AWS::S3::Object"]},
        {"Field": "resources.ARN", "StartsWith":
          ["arn:aws:s3:::clinic-docs/patients/"]}
      ]
    }
  ]'</code></pre>



<p class="wp-block-paragraph">S3 server access logs are the other option and they are not equivalent. They record HTTP-level request detail and are delivered on a best-effort basis with no delivery guarantee, which makes them useful for traffic analysis and weak as evidence. CloudTrail data events carry caller identity, which is the field you actually need when the question is &#8220;who.&#8221;</p>



<p class="wp-block-paragraph">Two things to sort out while you&#8217;re in there. Send the trail to a bucket in a different account, or at minimum a bucket the portal&#8217;s roles cannot write to, so that compromising the application doesn&#8217;t compromise its own log. And put a lifecycle rule on the log bucket, because data event volume grows with usage and nobody budgets for it. If you already run centralized dashboards on something like Grafana Cloud or CloudWatch, wire an alert to unusual read volume per identity now rather than after the incident.</p>



<h2 class="wp-block-heading">Cutting off access when someone leaves</h2>



<p class="wp-block-paragraph">Disabling a Cognito user does not immediately end their session, and this surprises people. Tokens are self-contained. An access token already in a browser stays cryptographically valid until it expires, typically about an hour.</p>



<p class="wp-block-paragraph">Refresh tokens are the longer exposure. The default is thirty days, and app clients can be configured anywhere from sixty minutes to ten years. Ten-year refresh tokens on a portal holding medical records is not a hypothetical misconfiguration; it&#8217;s a checkbox someone ticked to stop users complaining about re-login.</p>



<p class="wp-block-paragraph">Cognito gives you revocation. Token revocation is enabled by default on newly created app clients. <code>AdminUserGlobalSignOut</code> revokes all of a user&#8217;s refresh, ID, and access tokens from the server side, and the <code>/oauth2/revoke</code> endpoint kills one refresh token and everything descended from it.</p>



<pre class="wp-block-code"><code># Server-side, authorized with IAM credentials. Revokes the user's refresh,
# ID and access tokens.
aws cognito-idp admin-user-global-sign-out 
  --user-pool-id us-east-1_EXAMPLE 
  --username patient-example

# Confirm revocation is switched on for the app client before you rely on it.
aws cognito-idp describe-user-pool-client 
  --user-pool-id us-east-1_EXAMPLE 
  --client-id EXAMPLECLIENTID 
  --query 'UserPoolClient.EnableTokenRevocation'</code></pre>



<p class="wp-block-paragraph">Now the part that catches teams out. AWS documents that a revoked token still passes verification in a generic JWT library that only checks signature and expiry. Revocation is enforced by Cognito, not baked into the token. If your API validates tokens by fetching the JWKS and calling a verify function, a revoked token sails straight through until it expires on its own.</p>



<p class="wp-block-paragraph">Which means short access token lifetimes are load-bearing. Revocation shortens the tail; it doesn&#8217;t close the window. Refresh token rotation, available on the Essentials and Plus feature tiers, replaces the refresh token on each exchange and narrows the window a stolen one is useful for. If you&#8217;re paying for Plus for threat protection features, you already have it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting the usual suspects</h2>



<h3 class="wp-block-heading">Every request returns access denied after login</h3>



<p class="wp-block-paragraph">Nine times out of ten the prefix in your policy variable doesn&#8217;t match the prefix in your bucket. Print the identity ID your session actually resolved to and compare it character by character against a real key. Remember the identity pool ID and the user pool sub are different values.</p>



<h3 class="wp-block-heading">Signature does not match on a presigned URL</h3>



<p class="wp-block-paragraph">Usually something modified the request after signing. A client library adding a header, a proxy rewriting the path, or an email gateway URL-encoding the query string. Compare the <code>X-Amz-SignedHeaders</code> list in the URL against what the client actually sent.</p>



<h3 class="wp-block-heading">Uploads succeed but downloads fail</h3>



<p class="wp-block-paragraph">Check the KMS key policy separately from the IAM policy. A role can hold <code>kms:GenerateDataKey</code> (enough to write) without <code>kms:Decrypt</code> (needed to read). The S3 error will not say the word KMS prominently, which is why this one burns an afternoon.</p>



<h3 class="wp-block-heading">Browser uploads fail before any request reaches S3</h3>



<p class="wp-block-paragraph">That&#8217;s CORS. The bucket needs a CORS configuration allowing your portal&#8217;s origin, the methods you use, and any headers the SDK sends. Browser preflight failures never appear in S3 logs, so check the browser console first.</p>



<h3 class="wp-block-heading">Nothing appears in CloudTrail</h3>



<p class="wp-block-paragraph">Data events are not shown in CloudTrail Event History. Query the destination bucket directly, with Athena or CloudTrail Lake, and allow a few minutes for delivery.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Granting <code>s3:ListBucket</code> without an <code>s3:prefix</code> condition, so patients can enumerate every key even though downloads are blocked.</li>

<li>Putting identifying information in object keys or file names. Assume the key will leak eventually and use opaque identifiers.</li>

<li>Emailing presigned URLs. The link is the credential, and inboxes get forwarded, backed up, and breached.</li>

<li>Extending refresh token lifetime to silence complaints about re-login, instead of implementing rotation.</li>

<li>Assuming default encryption means access control. It means the disk is encrypted, nothing more.</li>

<li>Enabling versioning without a lifecycle rule, so deleted documents linger indefinitely as noncurrent versions you forgot exist.</li>

<li>Skipping CloudTrail data events to save money, then discovering the gap during an access review.</li>
</ul>



<h2 class="wp-block-heading">Best practices for a secure patient document portal</h2>



<ul class="wp-block-list">
<li>Keep one bucket for patient documents and nothing else. Mixed-purpose buckets make policies ambiguous and audits slow.</li>

<li>Turn on S3 Block Public Access at the account level, not just the bucket. Account level survives someone creating a new bucket in a hurry.</li>

<li>Enable versioning and set a noncurrent version expiration rule, so an overwritten document is recoverable but not permanent.</li>

<li>Use a KMS customer managed key so decrypt authority is separable from S3 authority.</li>

<li>Require MFA for staff accounts. TOTP costs nothing to enable and staff accounts are the ones with breadth.</li>

<li>Sign download URLs on demand with short expiry, and always from a backend that logged why it signed.</li>

<li>Put the portal behind a WAF and rate limiting. Cloudflare in front of the application, or AWS WAF on the distribution, both work; the point is that credential stuffing against a login page is the most likely thing to actually happen to you.</li>

<li>Restrict administrative access to the AWS console and to any bastion by network as well as identity, whether that&#8217;s IAM Identity Center with condition keys or a VPN such as NordVPN or Surfshark for a small team without central identity.</li>

<li>Write down the retention period before launch, then encode it as a lifecycle rule. Retention decided later is retention never decided.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are S3 and Cognito enough for HIPAA compliance?</h3>



<p class="wp-block-paragraph">Both are covered under AWS&#8217;s HIPAA eligibility program, and Cognito&#8217;s documentation states it is HIPAA-BAA eligible. That makes them usable building blocks, not compliance. You need a signed Business Associate Agreement with AWS, and the configuration, access reviews, training, and documented policies remain yours. Service eligibility is a prerequisite, not an outcome.</p>



<h3 class="wp-block-heading">Should I use a Cognito identity pool or a backend API?</h3>



<p class="wp-block-paragraph">Use an identity pool when access is a pure function of identity and the rules will not change. Use a backend when documents have states, when a clinician gates release, or when you need to log intent alongside access. Most clinic portals become the second within a year.</p>



<h3 class="wp-block-heading">How long should a presigned URL last?</h3>



<p class="wp-block-paragraph">Minutes, not hours. Sign at click time. The maximum with long-lived credentials is seven days, but that ceiling is irrelevant here because the link is a bearer credential and a longer window is a longer leak.</p>



<h3 class="wp-block-heading">Can patients upload documents back to the portal?</h3>



<p class="wp-block-paragraph">Yes, using presigned PUT or presigned POST. Presigned POST is worth knowing about because its policy document can constrain content type and size before the object lands, which presigned PUT cannot. Either way, write uploads into a quarantine prefix, scan them, then move them.</p>



<h3 class="wp-block-heading">Does enabling CloudTrail data events cost much?</h3>



<p class="wp-block-paragraph">Data events are billed per event recorded with no free tier, unlike management events. For a portal serving a few thousand downloads a month the amount is small; for a bucket also serving application assets it is not. Scope your event selector to the patient prefix rather than the whole account and the question mostly goes away.</p>



<h3 class="wp-block-heading">What happens to sessions when I disable a user?</h3>



<p class="wp-block-paragraph">Nothing immediate. Existing access tokens remain valid until they expire. Call <code>AdminUserGlobalSignOut</code> to revoke tokens, and keep access token lifetime short so the residual window is measured in minutes.</p>



<h3 class="wp-block-heading">Do I need CloudFront in front of the bucket?</h3>



<p class="wp-block-paragraph">Not for correctness. It helps when patients are geographically spread, when you want signed cookies for a set of objects rather than one URL per object, or when you want a single edge to attach a WAF to. For a single-clinic portal, direct presigned URLs are simpler and one fewer thing to misconfigure.</p>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">A signed-in token proves who someone is. It does not decide what they may reach. In a <strong>secure patient document portal</strong> the boundary is the policy condition, the KMS grant, and the code that decides whether to sign a URL at all, and every one of those is something you have to write on purpose.</p>



<p class="wp-block-paragraph">The demo passes either way. The difference only shows up when someone reads a key name they shouldn&#8217;t have seen, and nothing in your system considers that an error.</p>



<p class="wp-block-paragraph">If you build one of these, spend an hour before launch on exactly two things: sign in as a test patient and try to list the bucket, then ask your logs who downloaded a specific file last week. If either answer is uncomfortable, you&#8217;ve found your next task.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your portal?</h2>



<p class="wp-block-paragraph">This is the kind of build where the gap between working and safe is invisible from the outside. I help small teams close it.</p>



<ul class="wp-block-list">
<li>Reviewing S3 bucket policies and Cognito identity pool roles for prefix isolation gaps, including the list-versus-get split most policies miss.</li>

<li>Designing the boundary: identity pool credentials in the browser versus a backend that brokers presigned URLs, with the trade-offs written down for your stakeholders.</li>

<li>Setting up KMS customer managed keys, key policies, and grant separation so decrypt authority is not implied by storage authority.</li>

<li>Turning on scoped CloudTrail data events, building the Athena tables to query them, and alerting on abnormal per-identity read volume.</li>

<li>Hardening the Cognito side: MFA, token lifetimes, refresh token rotation, and a revocation path that actually ends sessions.</li>

<li>Writing the whole thing as Terraform or OpenTofu so the next change is a reviewed pull request rather than a console click.</li>
</ul>



<p class="wp-block-paragraph">Send me your bucket policy and your identity pool role, with the account IDs redacted, and I&#8217;ll tell you what a signed-in patient can actually see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/secure-patient-document-portal-s3-cognito/">Building a Secure Patient Document Portal on S3 and Cognito</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</title>
		<link>https://john-nessime.com/blog/cloud-computing/cognito-mfa-healthcare-apps/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Account Recovery]]></category>
		<category><![CDATA[Adaptive Authentication]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Artifact]]></category>
		<category><![CDATA[Business Associate Agreement]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cognito]]></category>
		<category><![CDATA[ePHI]]></category>
		<category><![CDATA[Healthcare Cloud]]></category>
		<category><![CDATA[HIPAA]]></category>
		<category><![CDATA[Identity Propagation]]></category>
		<category><![CDATA[MFA]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Passkeys]]></category>
		<category><![CDATA[PHI]]></category>
		<category><![CDATA[Session Management]]></category>
		<category><![CDATA[TOTP]]></category>
		<category><![CDATA[WebAuthn]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=348</guid>

					<description><![CDATA[<p>A user pool set to "MFA: Required" can still let clinicians in on a password alone. This is a practical guide to Cognito MFA for healthcare apps: why federated users bypass enforcement, how the account recovery collision locks people out, why required MFA and adaptive authentication cannot coexist, and how to make session lifetimes match what your documentation claims.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cognito-mfa-healthcare-apps/">Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The finding came back from a penetration test on a patient portal: the tester signed in as a clinical user with nothing but a username and password. No second factor. No prompt. Straight into a screen with appointment history on it.</p>



<p class="wp-block-paragraph">The user pool was set to <code>MFA: Required</code>. Somebody had screenshotted that console page for the security questionnaire. The screenshot was accurate. It just didn&#8217;t mean what everyone assumed it meant.</p>



<p class="wp-block-paragraph">That gap is the whole problem with Cognito MFA for healthcare apps. The service gives you a setting that reads like a guarantee, and then quietly carves out entire classes of users, sessions, and sign-in paths that the setting never touches. Nothing errors. Nothing logs a warning. Your compliance evidence looks fine right up until someone actually tests it.</p>



<p class="wp-block-paragraph">This post covers what the MFA switch in Amazon Cognito actually controls, the five failure families that show up in real healthcare deployments, how to wire a user pool so the enforcement matches the claim, and what to check when a clinician calls the help desk because they can&#8217;t get in.</p>



<h2 class="wp-block-heading">What HIPAA actually requires from MFA right now</h2>



<p class="wp-block-paragraph">Worth getting this straight before touching any configuration, because a lot of vendor content is confidently wrong about it.</p>



<p class="wp-block-paragraph">The HIPAA Security Rule as it stands today does not name multi-factor authentication. It requires person or entity authentication, and it leaves the mechanism to your risk analysis. MFA is how most people satisfy that in practice, but the rule does not spell it out.</p>



<p class="wp-block-paragraph">What changes the picture is the Notice of Proposed Rulemaking that HHS published in the Federal Register on January 6, 2025. It would make MFA an explicit requirement for access to systems handling ePHI, with narrow exceptions, and it would remove the &#8220;addressable&#8221; escape hatch that currently lets organizations document a control as infeasible. This one matters enough to name the date, because timelines have moved: the comment period closed in March 2025, the rule is still proposed, and the Unified Agenda has pushed anticipated final action out to 2027. That estimate is not binding and has already slipped once.</p>



<p class="wp-block-paragraph">Practical read: build for the proposed rule now, but don&#8217;t tell a client the mandate is already law. It isn&#8217;t, and auditors know the difference.</p>



<p class="wp-block-paragraph">On the AWS side, Amazon Cognito is on the HIPAA Eligible Services list and is covered by the AWS Business Associate Agreement. Eligibility is a starting line, not a finish line. You still have to accept the BAA in each AWS account that touches PHI, and you still have to configure the service correctly. Nothing about creating a user pool makes it compliant.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family one: half your users never see the MFA prompt</h2>



<p class="wp-block-paragraph">This is the one that produced the pen test finding, and it&#8217;s the most common serious gap I run into.</p>



<p class="wp-block-paragraph">Cognito user pool MFA applies to <em>local</em> users. Those are the accounts that live in the user pool directory and authenticate against it. For <em>federated</em> users, the ones arriving through SAML or OIDC from a hospital identity provider, Okta, Entra ID, or an in-house ADFS, Cognito hands the entire authentication decision to the upstream IdP. It does not add a second factor. It cannot. It never sees the password.</p>



<p class="wp-block-paragraph">So a portal with patients as local users and staff federated from a hospital directory has exactly the split you&#8217;d fear: patients get MFA, clinicians don&#8217;t, unless the hospital IdP is enforcing it independently. And the hospital IdP is usually somebody else&#8217;s system, run by a team you don&#8217;t have a Slack channel with.</p>



<p class="wp-block-paragraph">There&#8217;s a second version of this. The app client, not the user pool, controls which authentication flows are permitted. If a legacy mobile client is still allowed to use a flow you thought you&#8217;d retired, that path stays open regardless of what the pool-level MFA setting says.</p>



<p class="wp-block-paragraph">What to do about it:</p>



<ul class="wp-block-list"><li>Inventory every app client attached to the pool. Not the ones you remember creating. Run <code>list-user-pool-clients</code> and account for all of them.</li><li>For each federated IdP, get written confirmation of what the upstream enforces. A trust relationship is not a control. Put the answer in your risk analysis where an auditor will find it.</li><li>Treat federation as a boundary you inherit risk across, and say so out loud in design reviews. This is the single most useful sentence you can contribute to a healthcare identity project.</li></ul>



<h2 class="wp-block-heading">Failure family two: the recovery attribute collision</h2>



<p class="wp-block-paragraph">This one is genuinely subtle, and it doesn&#8217;t bite during development. It bites during rollout, at scale, to the people least able to tolerate it.</p>



<p class="wp-block-paragraph">Cognito refuses to let a user&#8217;s MFA delivery method be the same channel as their account recovery method. The logic is sound: if a password reset code and a second factor both land in the same inbox, an attacker with that inbox has everything, and the second factor is decoration.</p>



<p class="wp-block-paragraph">The consequence is where it gets sharp. If MFA is required, a user has only an email address and no phone number, email is your account recovery channel, and they have not registered a TOTP authenticator, then that user <strong>cannot complete sign-in at all</strong>. Cognito returns an error. They are not locked out temporarily. There is no path forward from the login screen.</p>



<p class="wp-block-paragraph">Now think about who that describes in a healthcare deployment. Part-time nursing staff enrolled from an HR export that had work email but no mobile number. Older patients who gave an email at registration and nothing else. Contractors. Locums. It is never a small number, and they all arrive at the help desk in the same week.</p>



<p class="wp-block-paragraph">The same rule cuts the other way on password reset. A user whose preferred MFA is email cannot receive a reset code by email. If they have no phone number, self-service recovery is gone, and someone with administrative credentials has to intervene for every single one.</p>



<p class="wp-block-paragraph">Three ways out, in the order I&#8217;d reach for them:</p>



<ul class="wp-block-list"><li><strong>Make TOTP the default factor.</strong> An authenticator app doesn&#8217;t consume either the email or phone attribute, so the collision never occurs. This is the one I reach for first in staff-facing apps.</li><li><strong>Require both email and phone_number as sign-up attributes.</strong> When a user has both, Cognito automatically routes reset codes to whichever channel isn&#8217;t their MFA factor. Clean, but it forces a phone number on people who may not want to give one.</li><li><strong>Configure a recovery fallback.</strong> Set account recovery to prefer email but fall back to SMS, so users who aren&#8217;t eligible for email recovery still have a route.</li></ul>



<p class="wp-block-paragraph">Whatever you choose, run a query against your existing user records for accounts missing a phone number before you flip MFA to required. Finding those users in a report is cheap. Finding them through a help desk queue is not.</p>



<h2 class="wp-block-heading">Failure family three: required MFA and adaptive authentication are mutually exclusive</h2>



<p class="wp-block-paragraph">This trips up almost every security-conscious design I review, because both features are things a healthcare client will ask for by name.</p>



<p class="wp-block-paragraph">Cognito&#8217;s threat protection includes adaptive authentication: risk scoring on each sign-in, with automated responses like requiring MFA or blocking outright. To use those automated responses in full-function mode, <strong>MFA must be set to optional in the user pool</strong>. That is a documented constraint, not a bug. The mechanism only makes sense if there&#8217;s a state where MFA is not already being demanded.</p>



<p class="wp-block-paragraph">So you cannot check both boxes. Either MFA is required for everyone and adaptive responses have nothing to escalate to, or MFA is optional and risk scoring can escalate, which means low-risk sign-ins may proceed on a password alone.</p>



<p class="wp-block-paragraph">For anything touching ePHI, I&#8217;d take required MFA and give up the adaptive escalation. A control that always applies beats a control that usually applies, and &#8220;usually&#8221; is a hard thing to defend in an audit.</p>



<p class="wp-block-paragraph">You don&#8217;t have to give up the visibility, though. Threat protection runs in audit-only mode alongside required MFA. You still get risk signals, device fingerprinting, and compromised credential detection surfaced in CloudWatch and in the user event logs, without the enforcement conflict. That&#8217;s a genuinely good trade: enforcement from the required setting, detection from threat protection.</p>



<p class="wp-block-paragraph">Two things to know before you budget for it. Threat protection lives in the Plus feature tier, and audit-only mode is billed the same as full-function. There&#8217;s no free observation period. Export those events somewhere you&#8217;ll actually look at them, whether that&#8217;s a CloudWatch Logs destination feeding your SIEM, or a platform like Datadog or Grafana Cloud if that&#8217;s where your on-call already lives.</p>



<h2 class="wp-block-heading">Failure family four: the session outlives the shift</h2>



<p class="wp-block-paragraph">MFA protects the moment of sign-in. It does nothing about the eight hours after it. In healthcare that gap is unusually expensive, because the workstation is shared, the browser stays open, and the next person to touch the keyboard is a different human being.</p>



<p class="wp-block-paragraph">Cognito&#8217;s defaults are built for consumer apps. Access and ID tokens last one hour. Refresh tokens last thirty days. Neither default is wrong in general and both are wrong for a clinical workstation.</p>



<p class="wp-block-paragraph">The ranges you have to work with:</p>



<ul class="wp-block-list"><li>Access token and ID token: five minutes to one day, set per app client.</li><li>Refresh token: sixty minutes to ten years, set per app client. It cannot be shorter than the access token lifetime.</li></ul>



<p class="wp-block-paragraph">Now the part that catches people, and the reason I put this failure family on the list. <strong>If you use managed login, Cognito sets a browser session cookie valid for one hour.</strong> Setting your access token to fifteen minutes does not shorten the re-authentication window. During that hour the user&#8217;s session refreshes without presenting credentials again, because the cookie is still good. Teams configure short tokens, write &#8220;15-minute session timeout&#8221; in the security documentation, and the observable behavior is an hour.</p>



<p class="wp-block-paragraph">The second trap is revocation. Revoking a token, or a global sign-out, invalidates refresh tokens. It does not invalidate an access token that has already been issued. That token stays valid until it expires, and any API accepting it will keep accepting it. So the practical blast radius of a compromised session is your access token TTL, not the moment you clicked &#8220;sign out this user.&#8221;</p>



<p class="wp-block-paragraph">That&#8217;s the argument for short access tokens: it is the only lever that shortens the window between &#8220;we revoked access&#8221; and &#8220;access actually stopped.&#8221;</p>



<pre class="wp-block-code"><code># Tighten an app client used by clinical staff.
# Access tokens live 15 minutes; refresh tokens expire at the end of a shift.
aws cognito-idp update-user-pool-client 
  --user-pool-id us-east-1_EXAMPLE 
  --client-id EXAMPLECLIENTID 
  --access-token-validity 15 
  --id-token-validity 15 
  --refresh-token-validity 9 
  --token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=hours 
  --enable-token-revocation</code></pre>



<p class="wp-block-paragraph">Refresh token rotation is worth turning on where your client can support it. Each refresh returns a new refresh token and invalidates the old one, so a stolen refresh token is usable once at most. Two caveats that bite in practice: rotation does not extend the original lifetime, so a nine-hour refresh token still dies nine hours after sign-in no matter how often it rotated, and app clients with rotation enabled cannot use the older <code>REFRESH_TOKEN_AUTH</code> flow. That second one has broken more than one mobile release. Rotation requires the Essentials tier or higher.</p>



<p class="wp-block-paragraph">None of this replaces workstation-level controls. Screen lock policy, OS session timeouts, and endpoint hardening tooling like the utilities from O&amp;O Software do work your identity layer cannot do from the outside. If staff reach the admin console remotely, a managed VPN such as NordLayer or Surfshark reduces network exposure, but be honest in your documentation about what that is. It is not an authentication control, and treating it as one is exactly the reasoning auditors push back on.</p>



<h2 class="wp-block-heading">Failure family five: the factor you picked is phishable</h2>



<p class="wp-block-paragraph">Cognito offers SMS, email, and TOTP as second factors, plus passkeys as a first factor under specific conditions. They are not equivalent, and the console presents them as though they were.</p>



<p class="wp-block-paragraph"><strong>SMS</strong> is the weakest. SIM swap and SS7 interception are real, and an SMS code is trivially relayed through a phishing proxy. It has one genuine advantage worth naming: enrollment friction is near zero, and for elderly or low-technology-comfort patient populations that is not a small thing. An SMS factor that gets used beats a TOTP factor that gets abandoned. Just don&#8217;t put clinical staff behind it. There&#8217;s also an operational trap: if your account is in the Amazon SNS SMS sandbox in the relevant region, you must verify each destination number before delivery works, which turns a smooth rollout into a stalled one.</p>



<p class="wp-block-paragraph"><strong>Email OTP</strong> is roughly SMS-equivalent in phishing resistance and worse if the mailbox is on the same laptop as the session you&#8217;re protecting. It needs the Essentials tier or higher and a configured Amazon SES sending identity. Useful as a fallback, weak as a primary.</p>



<p class="wp-block-paragraph"><strong>TOTP</strong> is the sensible default for staff. It&#8217;s phishable, the code can be relayed in real time by an attacker-in-the-middle, but it&#8217;s immune to SIM swap and it doesn&#8217;t collide with your account recovery channel. Any standard authenticator works. If your organization already uses a password manager with built-in TOTP, such as 1Password or Bitwarden, enrollment gets meaningfully easier, at the cost of both factors living in one vault. That&#8217;s a trade-off to make deliberately rather than by accident.</p>



<p class="wp-block-paragraph"><strong>Passkeys</strong> are the only phishing-resistant option here, and their integration has a shape you need to understand. A passkey cannot be a second factor on top of a password in Cognito. It can satisfy the MFA requirement on its own, as a first factor, when you set <code>FactorConfiguration</code> to <code>MULTI_FACTOR_WITH_USER_VERIFICATION</code> in the pool&#8217;s WebAuthn configuration. The user verification step, biometric or device PIN, is what makes it count as multi-factor.</p>



<p class="wp-block-paragraph">That&#8217;s the path I&#8217;d push for privileged accounts: administrators, anyone with bulk record export, anyone who can change the pool configuration itself. Platform authenticators cover most staff; hardware keys like YubiKey are worth the cost for the handful of accounts whose compromise ends the conversation.</p>



<p class="wp-block-paragraph">One hard constraint to plan around: you cannot set MFA to required in a pool that allows email or SMS one-time passwords as a <em>first</em> factor. Passwordless OTP sign-in and required MFA are incompatible. Passkeys with user verification are the documented exception.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Wiring up Cognito MFA for healthcare apps</h2>



<p class="wp-block-paragraph">An order of operations that avoids locking people out. Do this in a non-production pool first, with synthetic users only.</p>



<ol class="wp-block-list"><li><strong>Accept the BAA</strong> in the AWS account through AWS Artifact, before any real user data lands in the pool. The BAA is per-account and is not inherited by new accounts.</li><li><strong>Audit your user records</strong> for accounts missing <code>phone_number</code> or <code>email</code>. This is the population that will fail after you enable required MFA.</li><li><strong>Decide the recovery configuration</strong> and set it explicitly, with a fallback channel, so the MFA factor and the recovery channel can never be the same for any user.</li><li><strong>Enable MFA methods with the pool set to optional first.</strong> Let users enroll voluntarily. Track enrollment coverage as a number you report weekly.</li><li><strong>Flip to required only when coverage is high</strong> and the stragglers have a support path. Managed login will prompt unenrolled users to set up a factor at next sign-in, but only when MFA is required.</li><li><strong>Tighten token lifetimes per app client</strong>, then verify the observable behavior rather than the configuration. If you use managed login, remember the one-hour cookie.</li><li><strong>Turn on threat protection in audit-only mode</strong> and stream the events out to wherever you review security signal.</li></ol>



<p class="wp-block-paragraph">The core enforcement setting, once you&#8217;re ready:</p>



<pre class="wp-block-code"><code># Require MFA pool-wide, TOTP as the available method.
aws cognito-idp set-user-pool-mfa-config 
  --user-pool-id us-east-1_EXAMPLE 
  --mfa-configuration ON 
  --software-token-mfa-configuration Enabled=true

# Set a specific user's preferred factor administratively.
aws cognito-idp admin-set-user-mfa-preference 
  --user-pool-id us-east-1_EXAMPLE 
  --username clinician@example.org 
  --software-token-mfa-settings Enabled=true,PreferredMfa=true</code></pre>



<p class="wp-block-paragraph">Note that <code>admin-set-user-mfa-preference</code> does not reset an existing TOTP registration. To issue a new authenticator secret for a user who lost their phone, you go through <code>AssociateSoftwareToken</code> and <code>VerifySoftwareToken</code>. Build that runbook before you need it, because you will need it, and improvising an identity-proofing procedure at 2pm on a Tuesday is how bad help desk habits start.</p>



<p class="wp-block-paragraph">A staging pool holding only synthetic patients doesn&#8217;t need to live inside the BAA account, and there&#8217;s no reason a test environment should carry production&#8217;s cost structure. A plain VPS from InterServer or Contabo is fine for the app tier in front of a throwaway pool, provided you are certain no real PHI can reach it. That certainty is the whole condition, and it&#8217;s worth writing down rather than assuming.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">A user gets an error at sign-in and never reaches an MFA prompt</h3>



<p class="wp-block-paragraph">Almost always the recovery collision. Check whether the account has both <code>email</code> and <code>phone_number</code>, and compare the populated attribute against the pool&#8217;s account recovery setting. If the only available attribute is also the recovery channel, and there&#8217;s no TOTP registration, the user has no eligible factor. Register TOTP for them or add the missing attribute.</p>



<h3 class="wp-block-heading">Sign-in succeeds without MFA even though the pool says required</h3>



<p class="wp-block-paragraph">Work backward from the user, not from the pool. Is this a federated user? Then Cognito is not in the authentication path and pool MFA does not apply. Is it their first sign-in after account creation? Cognito treats the initial verification message as the second factor on that first sign-in and prompts for factor registration afterward. Is a different app client involved with different permitted flows?</p>



<h3 class="wp-block-heading">Password reset stopped working after enabling MFA</h3>



<p class="wp-block-paragraph">Expected behavior, not a regression. The reset code cannot go to the same channel as the user&#8217;s MFA factor. Users with only one contact attribute lose self-service recovery. Add the second attribute or configure a fallback recovery mechanism.</p>



<h3 class="wp-block-heading">SMS codes never arrive</h3>



<p class="wp-block-paragraph">Check the Amazon SNS SMS sandbox status in the region holding your SNS resources. In the sandbox, destination numbers must be verified in SNS before delivery. Also confirm the IAM role Cognito assumes for SNS is present and correctly scoped.</p>



<h3 class="wp-block-heading">Repeated MFA failures lock a user out</h3>



<p class="wp-block-paragraph">After five unsuccessful MFA code attempts, Cognito starts an exponential timeout lockout. It resolves on its own. Most real cases are clock drift on the user&#8217;s phone breaking TOTP validation, so check that before assuming an attack.</p>



<h3 class="wp-block-heading">Tokens keep working after you revoked them</h3>



<p class="wp-block-paragraph">Correct and by design. Revocation kills refresh tokens; already-issued access tokens survive to their expiry. If that window is unacceptable for your risk profile, shorten the access token TTL. There is no other lever short of checking revocation on every API call.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list"><li>Treating the pool-level MFA setting as coverage for federated users. It is not, and this is the gap most likely to survive to production.</li><li>Storing PHI in Cognito custom attributes. Diagnosis codes, MRNs, and care team assignments do not belong in a directory record that gets copied into ID token claims, which then land in browser storage, proxy logs, and error reports. Keep the identity layer to identity.</li><li>Enabling required MFA in one deploy without an enrollment ramp. You will manufacture a support incident.</li><li>Assuming a short access token gives a short session when managed login is in play. The one-hour cookie governs re-authentication.</li><li>Believing revocation is immediate.</li><li>Leaving threat protection off because required MFA is on. Audit mode and required MFA coexist, and the detection signal is separately valuable.</li><li>Enabling refresh token rotation without checking whether your client uses <code>REFRESH_TOKEN_AUTH</code>. That combination is not permitted and it breaks at runtime.</li></ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list"><li>Separate app clients for patient-facing and staff-facing applications, with independently tuned token lifetimes. Staff sessions should not inherit consumer defaults.</li><li>TOTP as the baseline factor for anyone with access to more than their own record.</li><li>Passkeys with user verification for administrators and bulk-export roles.</li><li>Both <code>email</code> and <code>phone_number</code> collected at enrollment where the population allows it, purely to keep recovery working.</li><li>Access token TTL short enough that revocation means something. Fifteen minutes is a reasonable starting point for clinical apps.</li><li>Threat protection in audit mode, with events exported and actually reviewed.</li><li>Manage the pool in Terraform or OpenTofu so MFA settings, recovery configuration, and token lifetimes are reviewable in a diff instead of discovered in the console during an audit.</li><li>Test the MFA path in CI against a non-production pool. Configuration drift in an identity provider is silent by nature.</li></ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Is Amazon Cognito HIPAA compliant?</h3>



<p class="wp-block-paragraph">No service is HIPAA compliant by itself. Cognito is HIPAA <em>eligible</em> and covered under the AWS Business Associate Agreement, which means you may process PHI with it once you have accepted the BAA in that account and configured it appropriately. Compliance is a property of your whole system and your documentation, not of the service.</p>



<h3 class="wp-block-heading">Does HIPAA require MFA today?</h3>



<p class="wp-block-paragraph">Not explicitly. The current Security Rule requires person or entity authentication and leaves the method to your risk analysis. The proposed 2025 update would make MFA an express requirement, but it remains proposed and the timeline for final action has moved more than once.</p>



<h3 class="wp-block-heading">Can I use required MFA and adaptive authentication together?</h3>



<p class="wp-block-paragraph">Not in full-function mode. Adaptive authentication&#8217;s automated responses require MFA to be optional in the user pool. You can run threat protection in audit-only mode alongside required MFA, which keeps the detection signal without the conflict.</p>



<h3 class="wp-block-heading">Which MFA factor should healthcare apps use?</h3>



<p class="wp-block-paragraph">TOTP as the baseline for staff, passkeys with user verification for privileged accounts. SMS is defensible for patient populations where enrollment friction genuinely determines whether MFA gets used at all, but it should not protect clinical access.</p>



<h3 class="wp-block-heading">Does Cognito MFA apply to users signing in through our hospital SSO?</h3>



<p class="wp-block-paragraph">No. For federated users arriving via SAML or OIDC, Cognito delegates authentication entirely to the upstream identity provider and does not add a second factor. Enforcement has to happen at the IdP, and you should get that in writing.</p>



<h3 class="wp-block-heading">How short should session timeouts be for a clinical application?</h3>



<p class="wp-block-paragraph">Set access tokens short, commonly around fifteen minutes, and refresh tokens to roughly a shift length rather than the thirty-day default. Then verify what users actually experience, because managed login&#8217;s one-hour browser cookie can keep a session alive past your token settings.</p>



<h3 class="wp-block-heading">Can I store patient identifiers in Cognito user attributes?</h3>



<p class="wp-block-paragraph">You can, and I would avoid it. Custom attributes flow into ID token claims, which end up in browser storage, application logs, and error tracking. Keep an opaque internal identifier in the token and resolve clinical data from your application database behind its own authorization check.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">If you remember one thing about Cognito MFA for healthcare apps, make it this: the MFA setting is a claim about local users at the moment of sign-in, and nothing else. It says nothing about your federated clinicians, nothing about the session that continues for the next eight hours, and nothing about whether the factor you chose survives a phishing proxy.</p>



<p class="wp-block-paragraph">Verify enforcement by observing behavior, per user population, per app client. Not by screenshotting the console page. That screenshot is what got flagged in the pen test at the top of this post, and it was completely accurate.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help with Cognito and healthcare authentication?</h2>



<p class="wp-block-paragraph">I work with teams building and hardening authentication on AWS, particularly where a compliance obligation makes &#8220;it works&#8221; insufficient. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing an existing user pool for the gaps in this post: federated coverage, recovery collisions, token lifetimes, app client sprawl</li><li>Designing an MFA rollout that won&#8217;t generate a help desk queue, including the pre-flight query against your user records and the enrollment ramp</li><li>Implementing passkeys with user verification for privileged accounts, alongside TOTP for general staff</li><li>Moving user pool configuration into Terraform or OpenTofu so identity settings are reviewable and drift is visible</li><li>Wiring threat protection events into your existing monitoring and building the alerts that are worth waking someone for</li><li>Writing the technical evidence that maps your configuration to your risk analysis, in language an auditor accepts</li></ul>



<p class="wp-block-paragraph">If you have a user pool that isn&#8217;t behaving the way the console suggests it should, send me the output of <code>describe-user-pool</code> and <code>describe-user-pool-client</code> with the identifiers stripped, and I&#8217;ll tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cognito-mfa-healthcare-apps/">Cognito MFA for Healthcare Apps: The Settings That Look Compliant but Aren&#8217;t</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
