Building Secure Sessions in a Traditional Web App with Auth0
In my previous post I mapped out every session and token option Auth0 offers. In this post I will walk through building the first of those patterns properly: a traditional server-rendered web app, using Auth0's Next.js SDK as the concrete example, though the same principles apply to any server-side Auth0 integration.
The areas this post covers are as follows.
- Why the default cookie session quietly breaks session revocation
- Moving sessions to a database, and a subtlety in how the SDK keys them
- Getting Back-Channel Logout to actually fire — not just to validate a token
- Listing and revoking sessions from a user-facing UI via the Session Management API
- A database gotcha worth knowing about before you hit it in production
The Default Cookie Session's Hidden Limitation
Out of the box, Auth0's server-side SDKs store the session in an encrypted, HttpOnly cookie. This is a sensible default — no server-side storage to provision, no database round-trip on every request, and the token contents never reach client-side JavaScript. For a huge number of applications, this is all you need.
Here's where it breaks down. Say a user's account is compromised and you revoke their session through the Session Management API or the dashboard. You'd reasonably expect that user to be logged out. With a pure cookie session, they aren't. The cookie is a self-contained, encrypted blob — the SDK decrypts it locally and trusts what's inside until it naturally expires. There is no server-side record for a revocation to invalidate, because the "record" is sitting in the user's browser.
I hit this directly while building a demo of this exact pattern: revoked a session through the Management API, and the browser carried on as if nothing had happened. Nothing was actually wrong with the revocation — the session genuinely was deleted at Auth0's end. There was simply nothing downstream capable of noticing.
The fix is to move session storage server-side, and to wire up Back-Channel Logout so that a revocation is actually delivered to your application rather than just recorded at Auth0.
Database-Backed Sessions, and a Subtlety in the Store's Keys
Auth0's Next.js SDK supports a pluggable SessionDataStore interface — implement get, set, delete, and (recommended) update, and the SDK moves the entire session, tokens included, into whatever database you provide instead of the cookie.
export const databaseSessionStore: SessionDataStore = {
async get(id) { /* ... */ },
async set(id, sessionData) { /* ... */ },
async delete(id) { /* ... */ },
async update(id, sessionData) { /* atomic check-and-write */ },
async deleteByLogoutToken({ sid, sub }) { /* ... */ },
};The subtlety worth knowing before you build this: the id passed to get/set/delete is a randomly generated identifier the SDK creates and stores inside the encrypted session cookie. It has nothing to do with sessionData.internal.sid, which is Auth0's own session identifier — the one that shows up in the Session Management API and the one a Back-Channel Logout token references.
This matters because deleteByLogoutToken receives Auth0's sid (and/or sub), not your store's own key. If you've been storing sessions keyed purely by the SDK-generated ID with no way to look them up by Auth0's sid, you can't actually delete the right one when a logout notification arrives. The fix is straightforward once you know to look for it: store internal.sid and user.sub as queryable fields on the session record, and search by field rather than by document key.
async deleteByLogoutToken({ sid, sub }) {
if (sid) {
const matches = await sessions.where('internal.sid', '==', sid).get();
// delete matches
}
if (sub) {
// sub alone (no sid) means terminate every session for this user —
// that's what the OIDC Back-Channel Logout spec calls for
const matches = await sessions.where('user.sub', '==', sub).get();
// delete matches
}
}Getting Back-Channel Logout to Actually Fire
Here's a detail that isn't obvious from the SDK documentation alone: you almost certainly don't need to write a custom Back-Channel Logout endpoint. The SDK already mounts one — it verifies the incoming Logout Token's signature, issuer, and audience, and calls deleteByLogoutToken on your session store automatically. You just need to implement the store method above and register the endpoint's URL against your Auth0 application.
The gotcha is in what actually triggers Auth0 to send that Logout Token. Auth0 lets you choose which events fire a Back-Channel Logout notification, and the default configuration only includes standard user-initiated logout and identity-provider-initiated logout. Deleting a session administratively — through the Management API, or from the dashboard — is a separate event, and it is not enabled by default. If you build all of the above and then test it by revoking a session through the API, you'll get exactly the same silent non-result I described earlier: the deletion succeeds, nothing gets notified, and the browser keeps working.
The fix is to explicitly include that event in your application's configuration, alongside the standard logout events you'd expect to already be covered.
Once that's in place, the full loop closes properly: an admin (or an automated response to a fraud signal) revokes a session, Auth0 sends a signed notification to your application, your application deletes the matching database record, and the user's very next request finds no session and is forced to re-authenticate. That's the behaviour I'd assumed was happening from the start — it just needed the trigger explicitly turned on.
Listing and Revoking Sessions from Your Own UI
With the session store in place, the natural next step is giving users (or admins) visibility into their own active sessions, and a way to end one directly — the "sign out this device" pattern most people expect from any modern account settings page.
This is a straightforward Management API integration: a machine-to-machine client scoped to read:sessions and delete:sessions, called server-side so the token never reaches the browser. List a user's sessions, show device and location metadata (the API surfaces both an initial and a "last seen" IP, ASN, and user agent per session), and offer a revoke action per row backed by a DELETE call.
The one thing worth flagging here: revocation through this path is exactly the "session-deleted" event from the previous section. If you've enabled that event for Back-Channel Logout, the revoke button in your own UI and an admin using the dashboard both flow through the same propagation mechanism automatically. You don't need to build anything special to make your own revoke button "count" — it already does.
A Database Gotcha Worth Knowing About Early
One practical thing I ran into that's worth flagging before it costs you a debugging session: the SDK's TokenSet object has several optional fields — audience, scope, refresh_token, and others — that are genuinely undefined rather than omitted when they don't apply. If your Auth0 application isn't configured with a specific API audience, for instance, audience comes back undefined.
Some databases reject undefined values outright when you try to write them, rather than silently dropping the field. If you hit an opaque "invalid value" error the first time a real login tries to write a session, this is almost certainly why. Most database clients have a setting to strip undefined properties before writing rather than throwing — worth enabling proactively rather than after your first production login fails.
Get Started
The pattern that falls out of all of this — database-backed sessions, a working deleteByLogoutToken, the right event enabled for Back-Channel Logout, and a user-facing revoke action wired to the Session Management API — gives you a server-rendered application where "revoke this session" means what it sounds like it means, everywhere, immediately.
Next in this series: the same set of concerns, but for a single-page application, where the token never gets to sit quietly in a server-side session at all. That post covers DPoP sender-constraining and what changes when the client itself is the thing you can't fully trust.
For reference, the official documentation for the pieces covered here: