Building Genuinely Ephemeral Sessions with Auth0 Actions and Next.js
Auth0's Manage Sessions with Actions APIs give a Post-Login Action direct control over the Authorization Server's own session: cookie persistence, absolute expiry, idle expiry, all settable per login rather than fixed at the tenant level. It's a good fit for a kiosk app, or a step-up action surface for something sensitive, where you want every login treated as ephemeral rather than asking the browser to self-report whether it's a shared device.
The API itself is straightforward, but getting a login that's ephemeral end-to-end is not, because there are two sessions in play the moment you put an Auth0 tenant behind a Next.js app, and the Action only ever touches one of them. I built a small demo to make this concrete, and ran into two problems on the way that were more interesting than the feature itself: an access token that turned out not to be a JWT, and a "rolling" session that quietly wasn't rolling.
In this post I will detail the architecture I landed on, why the two session layers need to be configured to agree with each other, and how I proved the idle timeout was resetting on activity rather than just displaying a number that looked like it was.
The two session layers
A Post-Login Action's api.session calls only ever affect the session Auth0's Authorization Server holds for that login - the AS-side session. That's what governs whether a future login to the tenant can silently reuse an existing credential via SSO, and it's the layer setCookieMode, setExpiresAt, and setIdleExpiresAt operate on.
Separately, @auth0/nextjs-auth0 issues its own session cookie for the Next.js app, entirely independent of the AS-side one. By default that cookie has nothing to do with anything the Action just configured - it defaults to a three-day absolute duration and a one-day inactivity duration regardless of how short-lived the AS session is. A demo that only touched the AS-side session would leave you signed into the app itself for days after the tenant-side session was long gone, which rather defeats the purpose of calling the feature "ephemeral".
The fix is to configure the app's own session to match:
export const EPHEMERAL_ABSOLUTE_DURATION_SECONDS = 90;
export const EPHEMERAL_INACTIVITY_DURATION_SECONDS = 30;
export const auth0EphemeralSessions = new Auth0Client({
// ...
session: {
cookie: { name: 'ephemeral_sessions_session' },
absoluteDuration: EPHEMERAL_ABSOLUTE_DURATION_SECONDS,
inactivityDuration: EPHEMERAL_INACTIVITY_DURATION_SECONDS,
},
authorizationParameters: {
prompt: 'login',
scope: 'openid profile email',
},
});Two other settings matter here. prompt: 'login' forces a fresh login on every visit to this client - without it, a browser with an existing AS session from an earlier demo would silently reuse it via SSO, and the Post-Login Action would never fire. And the scope deliberately omits offline_access, so no refresh token is ever minted for this client. A refresh token is a standing credential; issuing one would undermine the entire premise of a session that's supposed to disappear when the tokens in front of it expire.

With both layers configured to the same durations, closing every window of the browser and reopening it is the only way to actually observe setCookieMode('non-persistent') doing its job - it changes what the browser does when it closes, not anything visible mid-session. Compare that against a normal login using the same session infrastructure: one survives a restart, the other doesn't.
The access token that wasn't a JWT
I wanted a protected API route that a client-side poller could hit to prove the session was still valid, and stop returning 200 once it wasn't. The obvious approach was to grab the access token out of the session and decode it as a JWT - split on ., base64-decode the middle segment, check exp.
That approach fell over immediately with an error to the effect of "5 parts instead of 3". A standard JWT is header, payload, and signature joined by two dots - three parts. This token had five. Decoding the first segment gave the answer:
{"alg":"dir","enc":"A256GCM","iss":"https://your-tenant.auth0.com/"}That's a JWE header, not a JWT header - enc is the tell, since a signed-only JWT has no encryption algorithm at all. alg: "dir" means direct encryption with a shared key, enc: "A256GCM" is the cipher. A JWE has five dot-separated parts (header, encrypted key, IV, ciphertext, authentication tag), which is exactly the "5 parts" the split produced. This tenant's client configuration returns an encrypted access token, and short of holding the decryption key server-side there's no way to peek inside it the way you can with an ordinary signed JWT.
Rather than fight the encryption, I moved the validity check onto something that didn't need decrypting at all: the ID token. The Post-Login Action adds a custom namespaced claim to it - mode, absoluteExpirySeconds, idleExpirySeconds, actionExecutedAt - and the ID token is a normal signed JWT regardless of what's happening to the access token. It's already been verified once by the time the SDK hands it to application code, so decoding its payload is enough:
const idToken = decodeToken(session.tokenSet.idToken ?? '');
const ephemeralClaim = idToken.payload?.['https://example.com/ephemeral'];
const now = Math.floor(Date.now() / 1000);
if (!ephemeralClaim?.absoluteExpirySeconds || ephemeralClaim.absoluteExpirySeconds <= now) {
return Response.json({ error: 'Session expired', valid: false }, { status: 401 });
}This is also a better fit for the demo than access-token introspection would have been even without the encryption wrinkle: the claim is self-contained proof that the Action ran, signed at login time, available immediately with no additional round trip needed to confirm it.
Proving the idle timeout actually resets
The custom claim answers "is this session still within its absolute window", but it can't demonstrate an idle timeout resetting on activity - it's a fixed snapshot taken once at login and baked into a token that never changes. To show idle resetting, I needed a live value, not a claim.
@auth0/nextjs-auth0's session object exposes session.internal.createdAt, which is the same input the SDK's own rolling-session logic uses. Reusing that logic in the API route gives a deadline that moves:
const appAbsoluteExpiresAt = session.internal.createdAt + EPHEMERAL_ABSOLUTE_DURATION_SECONDS;
const appIdleExpiresAt = Math.min(now + EPHEMERAL_INACTIVITY_DURATION_SECONDS, appAbsoluteExpiresAt);appAbsoluteExpiresAt is fixed the moment the session is created. appIdleExpiresAt is recomputed from now on every call, capped at the absolute deadline - so as long as something keeps calling this route, the idle deadline keeps sliding forward, and once nothing does, it stops sliding and eventually passes.
The catch is that "something keeps calling this route" only extends anything if the SDK actually rolls the session on that call, and rolling only happens through the SDK's middleware touching the request - reading the session from a Server Component or a plain route handler doesn't roll it by itself. This app dispatches all its Auth0 clients through a single proxy.ts (this project runs on a Next.js version where proxy.ts has replaced the older middleware.ts convention, so check your own version's file-convention docs before assuming the name), and my protected route wasn't in that client's dispatch list:
const EPHEMERAL_SESSIONS_PROTECTED = ['/demo/ephemeral-sessions', '/api/protected/test'];Without /api/protected/test in that list, calls to it fell through to a different client's middleware further down the file - one with nothing to do with this session - so it was never touched, never rolled, and the cookie's lifetime was fixed at login to whichever of the two durations was shorter. Every poll looked like activity to the demo, but none of it was activity as far as the session store was concerned. Adding the route to the list was the entire fix.
With that in place, a client-side poller calling the route every five seconds shows the idle deadline creeping forward on each successful call, while the absolute deadline sits still. Pause the polling deliberately and the idle deadline stops moving too, and waiting past it makes the next call fail on idle before the absolute window would ever have caught it - two independently observable countdowns instead of one number standing in for both.
Conclusion
Ephemeral sessions in Auth0 are a Post-Login Action away, but "ephemeral" only holds end-to-end if the layer sitting in front of the tenant is configured to expire on the same terms. Auth0's access tokens documentation covers the opaque-versus-JWT distinction, but not the JWE case I hit here, so don't assume either format from the docs alone - check the actual token your client gets back. If you're on @auth0/nextjs-auth0, check what your middleware or proxy file actually dispatches to before assuming a session is rolling just because rolling: true is the default.