Token lifecycle
Each session begins with a random opaque token. Rotation replaces the active credential while retaining consumed-token history for replay detection.
Creation
createSession() generates 32 random bytes, encodes them as base64url, and hashes the resulting string with SHA-256. The store receives the hash, user UUID, optional session name, and an expiration of Date.now() + expiryTTL.
Both SQL adapters insert the family and initial token in one transaction. A failed insert rolls back the attempt, leaving no partial family. The raw token is returned only after the store operation succeeds.
Validation
validateRefreshToken() hashes the supplied token and checks the stored record in this order:
| Condition | Status | Side effect |
|---|---|---|
| Token not found | missing | None |
| Family revoked | revoked | None |
| Token consumed outside grace | consumed | Revoke its family |
| Invalid or elapsed expiration | expired | None |
| Token consumed within grace and not expired | valid | Return userId and familyId |
| All checks pass | valid | Return userId and familyId |
Validation can mutate state
Validating a consumed refresh token at or after the grace boundary revokes its family. This method is not a read-only token inspection API.
Rotation
Family A
R1 [consumed] → R2 [consumed] → R3 [active]rotateRefreshToken() first validates the old token, generates a new credential, then calls consumeAndReplace(). Success returns status: 'rotated', the raw replacement refreshToken, and userId.
The replacement expires at the rotation time plus expiryTTL. Rotation does not enforce an absolute family lifetime.
If validation fails, rotation returns the same failure status and does not issue a replacement.
A consumed-token retry within reuseGraceTTL (default 30,000 milliseconds) returns { status: 'grace', userId } without another successor. The window starts at the original consumption timestamp and never slides on retries. Expiration and family revocation still apply. Set reuseGraceTTL: 0 to disable grace. Callers must preserve the replacement from the successful rotation; the store retains only hashes and cannot recover a replacement after a lost response.
A retry timeline
With a 30-second grace window, assuming the token has not expired and its family has not been revoked:
| Time since R1 was consumed | Operation | Result |
|---|---|---|
| 0 seconds | Successful R1 rotation | rotated, returns R2 |
| 5 seconds | Rotate R1 again | grace, no replacement |
| 20 seconds | Validate R1 | valid, with userId and familyId |
| Exactly 30 seconds | Rotate or validate R1 | consumed, revokes the family |
| After family revocation | Validate R2 | revoked |
Validation deliberately maps an eligible grace retry to valid; rotation exposes grace separately. A valid validation result therefore does not mean the token is unconsumed. Only rotated includes a new refresh credential. The grace window does not prove that a retry came from the original client.
Replay detection
If a previously consumed token is presented at or after the grace boundary, the family is revoked:
Replay R1 outside grace → consumed detected → Family A revoked
└─ R3 can no longer renewKeep consumed history for active families. Removing it would turn a known consumed token into a missing record and weaken replay detection. The cleanup API preserves that history for retained families.
Concurrent requests
Both SQL adapters conditionally update the predecessor only while consumedAt is null, then read its family and insert the replacement in the same transaction. If insertion fails, consumption is rolled back. A losing update cannot create another successor or overwrite the original consumption timestamp.
Both requests may pass preflight validation, but only one conditional update can consume the predecessor. The losing attempt raises TokenAlreadyConsumedError; Core rechecks current token state and returns grace within the window or enforces reuse revocation outside it. Missing, revoked, and expired results are also respected on recheck.
The conditional update prevents duplicate successors through the adapter operation. A conditional no-op update locks the active family before token consumption. Family and user-wide revocation update the same row: revocation ordered first prevents rotation; rotation ordered first commits before revocation covers its successor. A revoked family raises SessionRevokedError, which the core maps to revoked. Expiration is rechecked under the family lock using a fresh application-clock timestamp as the consumption decision point. At or after expiry, TokenExpiredError maps to expired without consuming the token or creating a successor. No expiration grace applies; expiry after the decision does not abort commit. Custom adapters must implement exclusive consumption and signal consumed-token conflicts with TokenAlreadyConsumedError.
See Policies & Guarantees for at-most-once consumption, atomic rotation, revocation ordering, successor uniqueness, and failure atomicity. A losing concurrent request does not itself establish credential theft; how to respond is a separate policy decision.
