TryMellon
Navigation

Handling session revocation

Pattern for invalidating local session caches when tryMellon revokes a session.

Handling session revocation

When you validate session tokens offline (recommended for performance), your service won’t know about a revocation until the token expires — unless you listen for the revocation webhooks.

The pattern below combines the two: offline JWT validation on every request, plus a short-lived deny list in Redis populated by session.revoked / session.logout / user.locked / credential.revoked.

Flow

sequenceDiagram
  participant U as User
  participant API as Your API
  participant Cache as Redis
  participant TM as TryMellon

  TM->>API: POST /webhook { type: "session.revoked", data: { session_id } }
  API->>Cache: SET deny:sess_xyz "1" EX <remaining_token_ttl>
  API-->>TM: 200 OK

  U->>API: GET /resource (Bearer token)
  API->>API: jose.jwtVerify(token, JWKS)
  API->>Cache: GET deny:<jti or session_id>
  Cache-->>API: "1"
  API-->>U: 401 Unauthorized

Node.js — Express + Redis

import Redis from 'ioredis';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import express from 'express';

const redis = new Redis();
const JWKS = createRemoteJWKSet(
  new URL('https://api.trymellonauth.com/.well-known/jwks.json'),
);

// 1. Webhook endpoint — populate deny list on revocation events.
const app = express();
app.post('/webhooks/trymellon', express.json(), async (req, res) => {
  if (!verifySignature(req)) return res.status(401).end();
  const { type, data } = req.body;

  switch (type) {
    case 'session.revoked':
    case 'session.logout': {
      const ttl = computeRemainingTtl(data.session_id);
      await redis.set(`deny:sess:${data.session_id}`, '1', 'EX', ttl);
      break;
    }
    case 'user.locked': {
      await redis.set(`deny:user:${data.user_id}`, '1', 'EX', 86400);
      break;
    }
    case 'credential.revoked': {
      await redis.set(`deny:cred:${data.credential_id}`, '1', 'EX', 86400);
      break;
    }
  }
  res.status(200).end();
});

// 2. Auth middleware — verify offline + check deny list.
async function authenticate(req, res, next) {
  const token = req.headers.authorization?.slice(7);
  if (!token) return res.status(401).end();

  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://api.trymellonauth.com',
  });

  const denied = await redis.exists(
    `deny:sess:${payload.sid}`,
    `deny:user:${payload.sub}`,
  );
  if (denied > 0) return res.status(401).end();

  req.user = payload;
  next();
}

Idempotency

Webhook deliveries are at-least-once. Use the envelope id:

const seen = await redis.set(`webhook:${event.id}`, '1', 'EX', 86400, 'NX');
if (!seen) return res.status(200).end();   // already processed

Why a deny list and not a positive cache

Maintaining a “currently valid” allow-list requires writing to cache on every login — which the SDK doesn’t trigger your backend on. The deny list flips it: assume valid, only record revocations. Memory footprint is bounded by (revocations per day) × (token lifetime), typically tiny.

Troubleshooting

SymptomCauseFix
Deny list grows unboundedForgot the EX TTL.Always set TTL ≥ remaining token lifetime.
User can re-login but old token still rejectedCorrect behavior — old token is dead. New tokens have a different sid.None.
Webhook fires twiceAt-least-once delivery.Use envelope id for idempotency (see above).