Skip to content

Add optional CounterStorage to prevent TOTP replay #69#90

Merged
BastiaanJansen merged 3 commits into
BastiaanJansen:mainfrom
Jaaap:counter-storage
Jul 2, 2026
Merged

Add optional CounterStorage to prevent TOTP replay #69#90
BastiaanJansen merged 3 commits into
BastiaanJansen:mainfrom
Jaaap:counter-storage

Conversation

@Jaaap

@Jaaap Jaaap commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Implements the counter storage proposed in #69, so TOTP codes can be truly one-time.

What this adds

  • A CounterStorage interface with a single method, markAsUsed(long counter).
    It atomically checks whether the given counter is greater than the last used counter and, if so, stores it.
    A
    storage instance is bound to a single identity (for example a user) — the same way a TOTPGenerator is bound to a single secret.
  • A built-in InMemoryCounterStorage: one shared backend for the whole application, bound to an identity per verification with forIdentifier(userId).
  • TOTPGenerator.Builder.withCounterStorage(...).
    When configured, verify() accepts a valid code only once; when a code matches within the delay window, the matched counter is atomically
    recorded and any replay is rejected.

Usage

// One shared backend for the whole application
private final InMemoryCounterStorage counterStorage = new InMemoryCounterStorage();

boolean checkSecondFactor(User user, String code) {
	TOTPGenerator totpGenerator = new TOTPGenerator.Builder(user.getTotpSecret())
		.withCounterStorage(counterStorage.forIdentifier(user.getId()))
		.build();

	return totpGenerator.verify(code);
}

For distributed systems, implement CounterStorage against a shared store such as Redis or Hazelcast (e.g. new RedisCounterStorage(pool, user.getId())), so a code consumed on one node cannot be replayed on another.
Entries may safely expire once outside the delay window.

Design notes

  • Fully backward compatible and opt-in: without a configured storage, verify() behaves exactly as before.

  • The interface is a single atomic check-and-store rather than separate get/set methods, so two concurrent verifications (or two nodes) can never both accept the same code.

  • Counters are monotonic per identity: consuming a code also invalidates older, not yet used codes within the delay window (in line with RFC 6238 §5.2).

  • On a match within the delay window, the matched counter is stored — consuming a delayed code from the previous window does not block the current window's code.
    Testing

  • Replay rejection, per-identity isolation, delay-window replay, current-code-after-older-code, invalid codes.

  • A concurrency test asserting that of 10 threads verifying the same code, exactly one succeeds.

@Jaaap Jaaap force-pushed the counter-storage branch from a7dc2b5 to c7f3178 Compare July 2, 2026 11:51
A CounterStorage is bound to a single identity and records the last used counter.
When configured on the builder, verify() accepts a valid code only once.
Includes a built-in in-memory implementation; custom implementations can use a shared store (Redis, Hazelcast) for distributed systems.
long counter = calculateCounter(clock, period);
return hotpGenerator.verify(code, counter, delayWindow);

if (counterStorage == null)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use curly braces

@BastiaanJansen

Copy link
Copy Markdown
Owner

Please merge main to your branch. The GitHub actions should succeed from now on.

@BastiaanJansen BastiaanJansen merged commit b8f6f1b into BastiaanJansen:main Jul 2, 2026
36 of 48 checks passed
@Jaaap Jaaap deleted the counter-storage branch July 2, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants