Skip to content
← ALL WRITING

2026-04-23 / 13 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 the part that matters for compliance.

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 code runs on Next.js 14 or 15 app router, Postgres, and any session library that lets you control the token issuance path (Auth.js with a custom adapter, Lucia, a hand-rolled implementation, or a managed vendor that exposes the refresh hook).

Token Family StateISSUED
first use
rotate()
reuse detected
ISSUED
ACTIVE
ROTATED
FAMILY REVOKED
normal transitionreuse detection kill
The refresh token family cycles through issued, active, rotated. A reuse of a rotated token revokes the whole family.

What HIPAA actually asks for

The Security Rule's automatic logoff standard at 45 CFR 164.312(a)(2)(iii) requires covered entities to "implement electronic procedures that terminate an electronic session after a predetermined time of inactivity." This is an addressable implementation specification, which in HIPAA-speak means you either do it or document why the equivalent measure is reasonable for your environment. For a web app handling PHI, you do it.

The specification does not set a specific timeout. NIST 800-63B, which HIPAA often defers to for technical specifics, recommends 15 minutes of inactivity for moderate-assurance sessions and allows up to 30 minutes for lower-risk contexts. The other number that matters is the absolute session cap, not tied to activity. NIST recommends reauthentication at 12 hours regardless of activity.

The rotation pattern is how you implement these two rules without logging the user out every 15 minutes of real use. Access tokens are short (15 minutes). Refresh tokens are longer (12 to 24 hours). Active use refreshes the access token silently. Inactivity lets it expire. Reaching the absolute cap forces a full reauthentication.

Step 1: pick your session model

Before code, decide the model. The three realistic choices:

Sliding with absolute cap. Every active request extends the session, up to a hard ceiling. Activity-based. Typical for a consumer-facing healthcare app where a member is expected to use the product for an extended session and getting kicked out mid-flow is bad UX.

Fixed with short reauthentication. Sessions expire at a hard time regardless of activity. Common in admin portals where the user's work is bounded, clinical review tools where the user wraps up and leaves, and back-office systems.

Hybrid. Sliding for the access token up to the access-token expiry, fixed for the refresh token. This is what I use most often.

I recommend hybrid for most healthcare-adjacent products because it satisfies both the automatic-logoff rule (access tokens expire on inactivity) and the absolute cap (refresh tokens expire at an absolute time regardless of activity).

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 means a token was stolen and is being used by two parties in parallel (the legitimate client and the attacker). 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:

// 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 is the UX. The server-side rejection is the enforcement. Both run. If the client's JavaScript is disabled or the timer fails, the server still rejects expired tokens and forces reauthentication. If the server's clock drifts, the client still locks the UI. Defense in depth, without either layer alone being sufficient.

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.

Step 5: test the reuse detection

The test that matters most for compliance is the one that proves reuse detection works. 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 on every commit that touches the auth module. The second test is the one a compliance auditor cares about: can a stolen token survive past the moment the legitimate client refreshes. The answer has to be no.

Reuse detection is the part most tutorials skip. It is also the part that matters for compliance.

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 teams building the commerce layer on the same foundation, the productized DTC stack diagnostic reviews the tracking and attribution surface with the same rigor.

FAQ

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

NIST 800-63B recommends 15 minutes of inactivity for moderate-assurance sessions and 12 hours as the absolute cap. Most healthcare apps I build use a 15-minute access token and a 12-hour refresh token family. Admin portals go shorter, typically 10 and 8. The exact numbers should match your risk analysis.

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. Many teams 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?

The simplest fix is to serialize refresh at the client: a single in-memory lock that ensures only one refresh request fires at a time per browser. If two tabs race past that (different browsers, different devices), one of them will succeed and the other will see the rotated token as expired on its next access-token refresh, which triggers a normal re-refresh. Reuse detection only fires if the same refresh token value is presented twice, which a race should not cause.

What about biometric or WebAuthn reauthentication at the cap?

The absolute cap recommendation from NIST is reauthentication, not necessarily password-based. WebAuthn or platform biometrics satisfy this and improve the user experience. Implement the cap as "at time X, require step-up auth," and let the step-up method be the strongest one the user has enrolled.

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

  • HIPAA Security Rule automatic logoff: 45 CFR 164.312(a)(2)(iii).
  • NIST Special Publication 800-63B, Digital Identity Guidelines, session management section; referenced for 15-minute inactivity and 12-hour absolute recommendations.
  • Refresh-token-rotation-with-reuse-detection pattern: standard practice documented by the OAuth Working Group and IETF draft-ietf-oauth-security-topics; the HIPAA application is additive.
  • SQL schema uses standard Postgres 15 primitives (gen_random_uuid, timestamptz); portable to Supabase, Neon, managed RDS.
  • Code samples are Next.js 15 app router with server actions; pattern is framework-agnostic where a refresh endpoint exists.
  • All patterns derived from production authentication systems built for regulated member platforms without naming specific clients or vendors.

// 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