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
| Symptom | Cause | Fix |
|---|---|---|
| Deny list grows unbounded | Forgot the EX TTL. | Always set TTL ≥ remaining token lifetime. |
| User can re-login but old token still rejected | Correct behavior — old token is dead. New tokens have a different sid. | None. |
| Webhook fires twice | At-least-once delivery. | Use envelope id for idempotency (see above). |