Short answer: verify logout from a fresh client with the old credential, then inspect the authorization decision at the resource boundary. A UI redirect only proves navigation. For a customer-support account deletion, success means every session is denied, refresh credentials cannot mint new access, and a repeated request leaves no recoverable session behind.
I care about the first useful probe. If I need a full browser test suite before I can answer one security question, the interface is hiding the real state. I've learned to start with one account, one session, and a trace ID.
Test it twice.
Denied.
What must be true after account deletion and logout?
Treat logout as a state transition, not a button click. The account record moves to deletion_pending or deleted, the session registry marks every session revoked, and the token verifier checks that state before serving protected support data. The exact labels are yours; the ordering is the important part.
For GDPR deletion, keep an audit event with the subject identifier, actor, timestamp, reason, and correlation ID. Do not keep raw access tokens in that event. Store a hash or an opaque session ID so an operator can prove what happened without creating another credential store.
The common failure is a split-brain decision. The logout handler writes to the primary database, while an API worker reads a replica or a cache that is seconds behind. The customer sees the account disappear from the dashboard, but an old support tab can still load a ticket. That is not proof that revocation failed; it is proof that the read path has a different freshness contract.
The other failure is token shape. A self-contained access token can remain cryptographically valid until its expiry even after the session row is revoked. If immediate denial is required, the resource server needs a revocation check, a short token lifetime with refresh rotation, or an introspection-style decision that can see the session state. Pick one deliberately.
How can a support team verify a revoked session without trusting the UI?
Use two independent observations: the client sees an authorization denial, and the service logs a denial reason tied to the same correlation ID. A 302 to /login is useful for UX, but it is not an access-control assertion. Test the protected endpoint directly.
Here is a small TypeScript probe. It deliberately keeps the endpoint generic so the test can run against a staging service, a local fake, or a contract test. The important detail is that the second request reuses the old bearer value after the delete operation.
type Check = {
status: number;
body: string;
};
async function request(url: string, token?: string): Promise<Check> {
const response = await fetch(url, {
headers: token ? { authorization: `Bearer ${token}` } : undefined,
});
return { status: response.status, body: await response.text() };
}
const base = process.env.SUPPORT_API_URL ?? "http://localhost:3000";
const oldToken = process.env.OLD_SESSION_TOKEN;
if (!oldToken) throw new Error("OLD_SESSION_TOKEN is required");
const trace = crypto.randomUUID();
const idempotencyKey = crypto.randomUUID();
const deletion = await fetch(`${base}/account/delete`, {
method: "POST",
headers: {
authorization: `Bearer ${oldToken}`,
"content-type": "application/json",
"x-correlation-id": trace,
"idempotency-key": idempotencyKey,
},
body: JSON.stringify({ reason: "gdpr_request" }),
});
if (deletion.status === 429) {
throw new Error("retry this idempotent request with exponential backoff");
}
if (deletion.status !== 202 && deletion.status !== 204) {
throw new Error(`delete request was ${deletion.status}`);
}
const after = await request(`${base}/support/tickets`, oldToken);
if (![401, 403].includes(after.status)) {
throw new Error(`revoked session still reached tickets: ${after.status}`);
}
const anonymous = await request(`${base}/support/tickets`);
if (![401, 403].includes(anonymous.status)) {
throw new Error(`anonymous request was accepted: ${anonymous.status}`);
}
console.log(JSON.stringify({ trace, revoked: true, oldTokenStatus: after.status }));
For a real support migration, run this against browser cookies, mobile refresh tokens, and an already-open agent tab. Record the correlation ID for every attempt, replay each case after a worker restart, and compare the authorization log with the response body. That longer pass catches the gap where the data store says revoked while a warmed process still trusts an in-memory session object.
The probe checks behavior, not implementation. A service may use opaque sessions, JWTs plus a deny list, or an introspection endpoint. Those designs have different latency and storage costs, but they should produce the same boundary result: the old credential cannot read a ticket.
If the delete endpoint returns 429, retry with exponential backoff and the same idempotency key; never create a second deletion job because the first response was delayed.
Run the probe from a second client too. A browser can retain a service worker cache, a mobile client can queue a request offline, and a reverse proxy can replay a cached 200 response if cache headers are wrong. A fresh network path removes those distractions.
Where does a false positive hide?
First, check which credential the test actually sent. DevTools often shows a new cookie after the redirect, while the test author thinks they are replaying the revoked one. Log only a stable fingerprint, such as the first eight characters of a SHA-256 digest, never the token itself.
Next, compare clocks. If the verifier accepts exp using a clock that is two minutes ahead of the issuer, a newly minted token can look expired; if it is behind, an expired token can look usable. Keep server clocks synchronized and record the verifier timestamp beside the decision.
Then inspect caching. Protected responses should not be shared across users. Cache-Control: private, no-store is a safer default for support tickets, and a cache key must include the authorization context when caching is truly required. A correct revocation record cannot help if an intermediary serves yesterday's body.
The useful diagnosis is a timeline, not a single status code. At 09:14:02 the delete request may commit the account state; at 09:14:03 a queue consumer may mark one session; at 09:14:04 a second consumer may update the refresh-token family; and at 09:14:05 the resource check may finally observe the revocation. During that interval, a support agent can report that logout “did not work” even though the system is converging exactly as designed. Capture each transition with the same correlation ID, include the datastore version or event offset, and make the test assert the documented boundary rather than an accidental millisecond. If your promise is immediate denial, the check must read a strongly consistent revocation source. If your promise allows a bounded window, expose that window and alert when the p99 crosses it.
Finally, look for asynchronous deletion. If an event queue fans out revocation work, expose a status that tells the test whether all session records have been processed. Do not call the account deleted merely because the first database write succeeded. The user-facing contract needs a clear boundary: before that point, the account is pending; after it, every protected read denies the old session.
I am not sure which consistency window your queue can guarantee without measuring it. That is the point: capture p50 and p99 from the deletion request to the first guaranteed denial, then set the product promise from those observations instead of guessing.
What should change at scale, and what is the trade-off?
At scale, make revocation a monotonic record keyed by account and session. Consumers can retry the event because applying revoked_at twice has the same result. Add a dead-letter path and an operator view that shows the correlation ID, last consumer offset, and the denial check. Keep the authorization decision close to the protected resource, where a stale UI cannot make a dangerous claim.
A deny list gives fast emergency invalidation but adds a lookup on every request. Very short access-token lifetimes reduce that lookup pressure but make refresh rotation and clock handling more important. Stateless validation is cheap to run, yet it cannot promise immediate logout on its own.
The catch is operational complexity. A small internal tool with low-risk data may be better served by server-side sessions and a single transactional store. A support platform handling personal data should accept the extra lookup and audit work when the requirement is immediate revocation. Stick with a purely stateless token check only when the business can tolerate its expiry window and has documented that choice.
Measure the denial path under load, include retries, and test a revoked session from a different network. A green logout screen is not evidence. The denied request is.
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- RFC 7009, OAuth 2.0 Token Revocation: https://www.rfc-editor.org/rfc/rfc7009
- MDN, HTTP caching controls: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
Top comments (0)