skip to content

How do you test that a revoked, expired or tampered credential is actually refused?

level: middleimportance: nice to knowfreq 27%

answer

  1. One word hiding three different refusals
  2. Control the clock, never sleep
  3. Revocation has a promised window
  4. Change one field and resend
  5. Removing the integrity field is a case

basics

~20 s

Treat the three as separate cases. Expiry needs a controllable clock rather than a sleep, revocation needs a stated bound on how long access may survive withdrawal, and tampering means altering one field and asserting refusal rather than silent acceptance.

solid answer

~50 s

Expiry, revocation and tampering fail differently and need different cases. For expiry, mint a credential with a short stated lifetime and advance an injectable clock past it rather than sleeping — then assert the boundary from both sides so the case proves the limit, not an unrelated failure. For revocation, first establish the product's promise: refusal on the very next call, or access ending within a stated window; sign out, remove a role mid-session, and confirm that withdrawing a long-lived credential also stops it being exchanged for short-lived ones. For tampering, change exactly one thing and re-send — the actor identifier, the stated role, the expiry, the intended audience — and include the case where the integrity field is removed entirely. Add replay of a one-shot action. Every case asserts a distinguishable refusal, unchanged state, and the recorded attempt.

code

pseudocode · 10 lines
pseudocode
credential = mint_credential(actor="teacher", lifetime_seconds=180)

clock.advance(seconds=179)
assert call("view_timetable", credential).status == 200

clock.advance(seconds=2)
result = call("view_timetable", credential)
assert result.status == 401
assert result.body.does_not_contain(credential.value)
assert audit_contains(credential.id, "rejected_expired")

go deeper

for a junior

Know that expired, revoked and tampered are three different situations, and that a test which signs out and calls one endpoint has covered only one of them.

for a middle

Explain the mechanics: an injectable clock or a short configurable lifetime instead of a sleep, assertions on both sides of the expiry boundary, and one-field-at-a-time tampering variants.

for a senior

Push on the product's stated promise for revocation and on the exchange path where a withdrawn long-lived credential keeps minting short-lived ones. Show that the refusal assertion includes unchanged state and a recorded attempt.

for a principal

Own the guarantee itself: what bound the organisation commits to between withdrawal and loss of access, what it costs to shorten, and how that commitment is verified continuously rather than once.

### Three different refusals wearing one word "The credential should be rejected" hides three distinct cases with three distinct mechanics, and an interviewer asking this is checking whether you separate them. **Expiry** - the credential was valid and its stated lifetime has passed. **Revocation** - the credential's lifetime has not passed, but the authority behind it withdrew it: the actor signed out, an administrator removed their access, a password changed, the account was suspended. **Tampering and misuse** - the credential was altered, or it is being presented somewhere it was never intended to be used: a different audience, a different scope, a different account. They fail differently in production and so they need different cases. Expiry is arithmetic against a clock. Revocation is a distributed-state problem with a lag window. Tampering is an integrity check. A suite that tests only "sign out then call an endpoint" has covered one of the three. ### Controlling time without sleeping The naive expiry case waits for the credential to expire. That makes the case slow if the lifetime is realistic and unstable if it is short, and a fixed sleep in a test is a smell in its own right. Two techniques replace it, and being able to name both is what separates a considered answer from a guess. First, make the lifetime an input: the test environment mints a credential with a lifetime measured in seconds, then advances past it. Second, and better, make the clock injectable so the case advances time deterministically instead of waiting - mint with a lifetime of 180 seconds, advance the clock by 181, and call. The case then runs in milliseconds and cannot flake. Alongside the expired case, assert the boundary: a call one second before expiry still succeeds, so the case proves the boundary rather than proving that a broken credential fails for some unrelated reason. Skew deserves a case of its own. If verification tolerates a small clock difference between issuer and verifier, that tolerance is a stated rule and should be exercised at both edges, since a tolerance introduced to fix a support ticket has a way of growing until it swallows the lifetime. ### Revocation and its window Revocation testing has to establish what the product actually promises. Some systems check a withdrawal list on every request, in which case a revoked credential must be refused on the very next call. Others accept the credential until it expires, in which case the promise is "access ends within N minutes" and the case must assert that bound, not immediate refusal. Getting the team to state which one it is is often more valuable than the test. The cases worth writing: sign out, then re-present the same credential - refused. Remove a role from an actor mid-session, then perform the operation that role permitted - refused, and refused for the right reason rather than because the record was also deleted. Where a long-lived credential can be exchanged for a short-lived one, revoke the long-lived one and confirm the exchange stops; a common gap is that the withdrawal is enforced on the short-lived credential but the exchange keeps minting new ones. ### Tampering, replay and audience For tampering, the case changes exactly one thing and re-sends, and the expected result is a refusal - never a silent acceptance of the altered value. Useful variants: change the actor identifier inside the credential to another actor; raise the stated role; extend the stated expiry; present a credential issued for a different scope, a different account, or a different service; truncate the credential; present it with its integrity field removed entirely. That last one is worth writing even though it looks trivial, because "no integrity field means nothing to check, therefore accept" is a real implementation shape and no other case finds it. **Replay of a one-shot action** belongs here too, and it is distinct from a stale credential. The same valid request, sent twice - by a duplicate submission, an impatient retry, or a captured request re-issued later - must either be absorbed or refused according to a stated rule. In a school timetable planner, a single "release the term to parents" request replayed once produced two release notifications to every family, because an **ordering assumption** held: the code marked the release complete after sending, and the second copy arrived before the mark landed. A four-person team read that as a mail-provider fault for a week. The case is: capture the request, resend it, assert the second attempt changed nothing and that exactly one notification exists. ### What the refusal must look like Every case here asserts the same shape as the rest of this area. The refusal is distinguishable from a crash and from a routing miss. No state changed. The message does not disclose why the credential failed in a way that helps someone tune an attempt. The rejected attempt is recorded, with the acting identity where it is known - and the credential itself is never written into that record.

  • Why is asserting only the expired call insufficient for an expiry case?
    Because a refusal one second after expiry is also produced by a credential that was never accepted at all, a mistyped route, or an environment where the actor lacks the permission anyway. Asserting a successful call just before the limit and a refusal just after proves the boundary is where the product says it is, and makes the case fail loudly if the lifetime is silently changed.
  • A revoked credential still works for a few minutes. Is that a defect?
    Only against a stated promise. Some systems consult a withdrawal list on every request, so revocation must take effect on the next call; others accept a credential until it expires, so the promise is that access ends within a bounded window. The test asserts whichever the product commits to. Getting the team to state which one it is — and to write the bound down — is usually worth more than the case itself.
  • Why write a case that removes the credential's integrity field entirely?
    Because "there is nothing to verify, so accept it" is a real implementation shape, and no other case reaches it. A variant that alters a field still presents something to check and will be refused by a working verifier; a credential presenting no integrity field at all exercises the branch where verification is skipped rather than failed. It is cheap to write and catches a total bypass.

saying these in an interview costs you the question

  • Waiting out a real lifetime with a fixed sleep
  • Testing sign-out only and calling credential handling covered
  • Asserting refusal without checking state was unchanged
  • Assuming revocation is instant without a stated promise
  • Treating a tampered field as accepted-but-ignored rather than refused
  • Logging the rejected credential itself into the audit record

context