Skip to content
bizurk
← ALL WRITING

2026-04-10 (Updated 2026-10-23) / 14 MIN READ

Session and token rotation patterns for healthcare apps

A working pattern for session and refresh token rotation in regulated apps: sliding sessions, reuse detection, and the rotation that satisfies automatic logoff.

The first time I shipped authentication for a regulated product, I thought rotation meant "new token every time you refresh." It does. It also means detecting when an old token tries to refresh after a new one has already been issued, because that is what a stolen token looks like from the server's point of view. The detection is the part most tutorials skip, and it is part of the session security design.

This tutorial walks through the session and refresh token rotation pattern I use for HIPAA-adjacent Next.js apps. Sliding sessions, access-and-refresh token pairs, rotation with reuse detection, automatic logoff wired into both client and server, and the audit-trail hook that connects all of this to the audit log from my earlier pattern for regulated Next.js audit logging. Session plumbing sits inside the broader regulated DTC healthcare overview, which maps how auth fits alongside audit, analytics, and vendor posture.

The schema and examples below describe the token family pattern. Connect them through the supported session and transaction APIs of the library you actually use. The request handler, token signing helper, cookie policy, and authorization checks belong to that integration.

The temple silhouetted dark against a bright pink and purple sunset, multi-tiered roof dominant, sunset glow concentrated directly behind.
// silhouette · backlit by sunset

What HIPAA actually asks for

Automatic logoff is an addressable implementation specification in the HIPAA Security Rule. Its treatment depends on the entity's documented assessment of what is reasonable and appropriate. HHS explains that assessment and any alternative measures in its addressable specifications guidance. Have the organization's security and legal owners review the policy for the actual environment.

NIST guidance is a separate technical standard. In SP 800-63B-4, section 2, AAL2 requires a defined overall reauthentication timeout and recommends limits of 24 hours overall and one hour of inactivity. AAL3 requires an overall limit of twelve hours and recommends fifteen minutes for inactivity. Those values describe assurance levels, rather than a universal healthcare timer.

An access token's expiry, user inactivity, and the maximum time since authentication are separate controls. Background refresh traffic can continue while a person is away. Track relevant user activity on the server and enforce the policy there, with a client lock screen as a supporting interface.

Step 1: pick your session model

A fixed family expiry gives a clear maximum session length. Each rotated refresh token inherits the original deadline. That is straightforward for a reviewer tool with bounded work sessions, but the user still needs a way to save work before reauthentication.

A sliding refresh expiry can improve continuity during active use. It needs a separate family authentication deadline, otherwise repeated refreshes can keep the session open indefinitely. Store that deadline independently and check it during refresh and protected requests.

A layered model keeps access token lifetime, refresh rotation, user inactivity, and the overall deadline explicit. The ID token and CSRF token have different jobs, so their renewal policies need their own rationale. Follow the identity provider and framework's supported behavior instead of assuming that every credential should rotate on the same trigger.

Record the session decision

The earlier decision log described a platform configured with a fifteen minute access token, refresh rotation on each use, and a twenty four hour family cap. It also described renewing identity claims at refresh and rotating the CSRF token at session creation and privilege changes. Treat that as a dated implementation example. The archived account of an audit conversation is not evidence that the same settings satisfy another organization's obligations.

For each surface, record the user role, device assumptions, data exposure, inactivity rule, overall deadline, refresh behavior, and the person who approved the policy. Add the review date and the tests that show the deployed behavior matches it. A change from twelve to twenty four hours needs a recorded reason as much as a shorter timeout does.

Keep expiry, inactivity, explicit logout, administrative revocation, and token reuse as separate events. That makes a session history understandable without reconstructing the code. Show the policy with a test account, exercise the expiry boundaries, and query for sessions that exceeded the allowed deadline.

Step 2: implement refresh-token rotation with reuse detection

The core of the pattern. When the client's access token is about to expire, it sends the refresh token to the server and gets back a new access token and a new refresh token. The old refresh token is invalidated immediately.

If the old refresh token is ever used again, that is a reuse event. Reuse detection is the security value: it can indicate theft, replay, or a legitimate request race. Investigate the cause while applying the configured revocation policy. The response is to revoke the entire token family, which forces both parties to reauthenticate.

create schema auth;

create table auth.refresh_tokens (
  id              uuid primary key default gen_random_uuid(),
  family_id       uuid not null,
  user_id         uuid not null,
  token_hash      text not null unique,
  issued_at       timestamptz not null default now(),
  expires_at      timestamptz not null,
  rotated_at      timestamptz,
  replaced_by     uuid references auth.refresh_tokens(id),
  revoked_at      timestamptz,
  revoked_reason  text
);

create index on auth.refresh_tokens (family_id);
create index on auth.refresh_tokens (user_id);

The family_id is what ties all rotated tokens in a single login session together. Each login creates a new family. Every rotation creates a new row with the same family_id and sets the previous row's rotated_at and replaced_by.

The server logic on refresh needs one database transaction around the lookup and replacement. Lock the token row with SELECT ... FOR UPDATE, then check family revocation, inactivity, and the original authentication deadline before issuing anything. The helper below shows the transitions; its db and signing helpers stand for the application adapter. Add the transaction and family checks in that adapter before using it for protected requests.

// src/lib/auth/rotate.ts
import { randomBytes, createHash } from "crypto";
import { db } from "@/lib/db";
import { writeAudit } from "@/lib/audit";

export async function rotateRefreshToken(
  presentedToken: string,
  requestId: string,
): Promise<{ access: string; refresh: string } | null> {
  const hash = createHash("sha256").update(presentedToken).digest("hex");
  const row = await db.query(
    `select * from auth.refresh_tokens where token_hash = $1`,
    [hash],
  );
  if (!row) return null;

  // Reuse detection: a token that has already been rotated cannot rotate again
  if (row.rotated_at !== null) {
    await db.query(
      `update auth.refresh_tokens
          set revoked_at = now(),
              revoked_reason = 'reuse_detected'
        where family_id = $1`,
      [row.family_id],
    );
    await writeAudit({
      actor_id: row.user_id,
      actor_type: "user",
      action: "session.token.reuse_detected",
      resource_type: "refresh_token_family",
      resource_id: row.family_id,
      success: false,
      request_id: requestId,
    });
    return null;
  }

  // Already revoked by a previous reuse event or explicit logout
  if (row.revoked_at !== null) return null;

  // Expired by absolute cap
  if (new Date(row.expires_at) < new Date()) return null;

  // Issue the new pair
  const newRefresh = randomBytes(32).toString("base64url");
  const newHash = createHash("sha256").update(newRefresh).digest("hex");
  const expiresAt = new Date(row.expires_at); // keeps the absolute cap
  const newId = await db.query(
    `insert into auth.refresh_tokens
        (family_id, user_id, token_hash, expires_at)
     values ($1, $2, $3, $4)
     returning id`,
    [row.family_id, row.user_id, newHash, expiresAt],
  );

  await db.query(
    `update auth.refresh_tokens
        set rotated_at = now(), replaced_by = $1
      where id = $2`,
    [newId, row.id],
  );

  const access = issueAccessToken(row.user_id);
  return { access, refresh: newRefresh };
}

Three things worth calling out. The token is hashed before storage; the raw bytes only ever live in the cookie. The reuse detection fires when a rotated token is presented again, and the response is to kill the whole family, not just that token. The absolute expiry does not reset on rotation; the new token inherits the original family's expiry.

Step 3: wire automatic logoff into the session

The client-side piece. Browsers do not automatically log users out on inactivity. You need both a client timer that redirects to a lock screen after inactivity, and a server-side check that rejects expired access tokens.

// src/components/session/inactivity-guard.tsx
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";

const INACTIVITY_MS = 15 * 60 * 1000;

export default function InactivityGuard() {
  const router = useRouter();
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    const reset = () => {
      if (timerRef.current) window.clearTimeout(timerRef.current);
      timerRef.current = window.setTimeout(() => {
        router.push("/locked");
      }, INACTIVITY_MS);
    };
    reset();
    const events = ["mousemove", "keydown", "touchstart", "click"];
    events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
    return () => {
      if (timerRef.current) window.clearTimeout(timerRef.current);
      events.forEach((e) => window.removeEventListener(e, reset));
    };
  }, [router]);

  return null;
}

The client timer provides the lock screen. Server checks must reject expired credentials and inactive or overlong sessions independently, including requests made with JavaScript disabled. Keep the server clock monitored and preserve unsaved work without extending authorization.

Ultra-wide of rippled sand dunes with leading lines stretching toward the distant temple silhouette small on the horizon.
// dunes leading · sand toward the temple

Step 4: log rotation events to the audit trail

Every rotation should be auditable. Every revocation definitely should be. The audit events to write:

session.login                 - user authenticated, family created
session.token.rotated         - normal rotation
session.token.reuse_detected  - reuse event, family revoked
session.token.expired         - absolute cap reached
session.logout                - explicit user logout
session.revoked               - admin or system revocation

Do not log the tokens themselves. Log the family ID, the user ID, and the event. The token values never appear in the audit trail. The audit row identifies the event; the row in auth.refresh_tokens carries the token hash if forensic investigation needs to correlate.

The audit write uses the same separate-role pattern I described in audit logging for regulated Next.js apps. The rotation path calls writeAudit after the token work is committed, so a failed audit write does not prevent the rotation itself, but the audit-write failure is logged to your error monitoring so it surfaces immediately.

Close-up of the base of the temple at sunset where the temple foundation meets the sand, weathered stone meeting rippled dune.
// where stone meets sand · the seam

Step 5: test the reuse detection

The reuse test checks that a rotated credential cannot keep a token family alive. Without this test, the rotation implementation is a code path that has never been exercised in the adversarial case.

// tests/auth/rotation.test.ts
import { rotateRefreshToken } from "@/lib/auth/rotate";
import { createSession } from "./helpers";

test("rotation works on first use", async () => {
  const { refresh } = await createSession("user-1");
  const result = await rotateRefreshToken(refresh, "req-1");
  expect(result).not.toBeNull();
  expect(result?.refresh).not.toBe(refresh);
});

test("reuse of a rotated token kills the family", async () => {
  const { refresh: t1 } = await createSession("user-2");
  const r1 = await rotateRefreshToken(t1, "req-2a");
  expect(r1).not.toBeNull();

  // The original t1 is now a rotated token. Reuse it.
  const r2 = await rotateRefreshToken(t1, "req-2b");
  expect(r2).toBeNull();

  // The newly-issued t2 must also be revoked by the family kill.
  const r3 = await rotateRefreshToken(r1!.refresh, "req-2c");
  expect(r3).toBeNull();
});

test("expired refresh token cannot rotate", async () => {
  const { refresh } = await createSession("user-3", { expiresInMs: 1 });
  await new Promise((r) => setTimeout(r, 5));
  const result = await rotateRefreshToken(refresh, "req-3");
  expect(result).toBeNull();
});

Run these in CI when the session module changes. Add boundary tests for server inactivity, the overall deadline, concurrent refresh, logout, and revocation. Test the deployed adapter as well as the helper so a passing unit test cannot hide a request path that skips the checks.

“The session policy needs tests at the same boundaries the request handler enforces.”
Ultra-wide of the vast empty desert at sunset with the single temple visible as a small silhouette in the right third, dwarfed by empty dunes.
// dwarfed · temple in vast emptiness

Common mistakes

Rotating the absolute expiry. If you reset expires_at on every rotation, the session never hits the hard cap. You have implemented sliding refresh instead of sliding access, which defeats the purpose of the pattern.

Logging tokens instead of hashes. The audit row or error log that includes a raw refresh token is a credential in plaintext. Hash before store, hash before log. Never write the raw token anywhere except the cookie.

Missing the client-side inactivity timer. Server-side rejection happens on the next request. If the user walks away from a logged-in screen and someone sits down 10 minutes later, there may be no request for a while. The client timer locks the UI.

Not revoking the family on reuse. Revoking only the presented token lets the attacker continue with the token they stole. The whole family must die, forcing the legitimate client to reauthenticate.

Writing rotation logic for each user without token families. Without families, you cannot tell which rotation belongs to which login. Reuse detection becomes guesswork. The family_id column is what makes the detection precise.

What to try next

The three extensions worth considering once the base pattern is in place.

Bind refresh tokens to the device. Add a device fingerprint column (hashed) and verify on rotation. A stolen refresh token presented from a different device fingerprint triggers a revocation. This adds false-positive risk (users change devices), so pair it with a clear reauthentication UX.

Surface the reuse events in a security dashboard. A reuse event is a security signal. Aggregate them per user, per IP, per device. Sudden spikes indicate active attack campaigns.

Stream token events to your SIEM. Once the audit trail is stable, stream new rotation rows to the security information and event management platform the compliance team uses. The same provider question from the vendor BAA evaluation applies.

For the wider data flow around sessions, continue with the App Router boundary guide.

Close-up wind-blown sand dune ripple patterns receding to a defocused horizon, no temple in frame, abstract sand texture.
// the ripples · wind patterns into haze

FAQ

What timeout should I use for access tokens and refresh tokens?

Set the values from the approved risk assessment and assurance requirements. Access token expiry, server inactivity, and the overall authentication deadline need separate enforcement. The NIST values and the dated platform example above are reference points for that decision.

Does this pattern work with NextAuth / Auth.js?

Yes. Auth.js exposes a JWT rotation hook where you can implement the refresh rotation, and a session callback where you can check absolute expiry. The database writes go to your own tables, not Auth.js internals. Operators I have worked with use the Auth.js session as the access-token equivalent and implement the refresh rotation separately.

Do I need a separate refresh token if I use HttpOnly cookies?

HttpOnly cookies reduce the XSS attack surface but do not remove the need for rotation. A stolen cookie is a stolen cookie. Rotation with reuse detection still provides the "parallel use" signal that HttpOnly alone cannot provide. Use both.

How do I handle the race condition where two tabs refresh simultaneously?

Serialize refresh across tabs where practical and make the server transition atomic. A race really can present the same token twice, so the policy must define how retries and concurrency are handled without silently disabling replay detection. Exercise that behavior in integration tests.

What about biometric or WebAuthn reauthentication at the cap?

Reauthentication needs an authenticator combination appropriate to the assurance level. Use the identity provider's supported flow and confirm its requirements before treating a local biometric check as sufficient.

Can I skip the audit trail for rotation if I have general application logs?

No. Application logs are mutable and may contain PHI incidentally. The audit trail is append-only, role-restricted, and built to survive an incident investigation. Rotation events belong in the audit trail because a compliance officer will need to reconstruct "when did this user's session start, rotate, and end" during a breach review.

Sources and specifics

The consolidated guide retains the refresh family schema, rotation transitions, inactivity component, and reuse tests from the implementation tutorial. Its separate cadence comparison and policy record come from the original decision log.

See the HHS audit protocol for the automatic logoff review and NIST SP 800-63B-4 for assurance level reauthentication guidance, checked October 23, 2026. The source's older AAL2 timing claim has been corrected. Example timer values and database helpers remain implementation choices that need review in their actual system.

// related

Let us talk

If something in here connected, feel free to reach out. No pitch deck, no intake form. Just a direct conversation.

>Get in touch

Tell me what you’re trying to ship.

Send a quick message and I read it within a day, or talk to AI Michael first if you want to feel out your project before you write to me.

By sending this, you agree to the Terms and acknowledge the Privacy Policy.