Stage 7 · Master
Phase 15 — Redis Integration
Session Storage
Move active session tracking into Redis so a terminated staff member or a compromised account can be revoked immediately, instead of waiting out a JWT's remaining lifetime.
A Valid Signature Is Not the Same as a Still-Valid Session
The Authentication phase's JWTs are stateless by design — any service can verify one without a database round trip. That design has a real cost: a JWT issued five minutes before a staff member is terminated, or before a security team forces a password reset, remains cryptographically valid until it expires on its own. Meridian needs a way to say 'this specific session is over' immediately, without waiting for expiry.
Staff termination already exists as a real workflow from Module 13. The moment a terminated guard's session should stop working immediately, rather than up to the JWT's remaining lifetime, is the concrete requirement driving this lesson — not a generic 'sessions might need revoking someday'.
Two Revocation Primitives Exist Already; Neither Covers Termination
Before building anything, look at what Authentication already shipped. A session there is a refresh-token family in Postgres, and revoking the family stops renewal. Access tokens are handled separately by a Redis denylist keyed on the token's jti, which auth-service writes when a user logs out. Between them, a user who logs out is fully cut off within one access-token lifetime.
Termination breaks both. The terminated guard never logs out, so nothing writes a jti to the denylist; and revoking the refresh family only stops the next renewal, leaving the current access token valid for up to fifteen more minutes. Worse, staff-service does not know the guard's outstanding jti values — they were minted by auth-service and never recorded anywhere staff-service can read. Enumerating them is not an option, so this lesson adds the one Redis primitive that does not require enumeration.
One Key Per User Beats One Key Per Token
A revocation epoch is a single timestamp per user meaning "reject every access token issued before this instant". It costs one Redis key per revoked user rather than one per outstanding token, it needs no knowledge of which tokens exist, and it expires on its own: once the epoch is older than the longest possible access-token lifetime, no live token can predate it, so the key can go.
package authctx
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// AccessTokenTTL mirrors the 15-minute access-token lifetime chosen in the
// Authentication phase. The epoch key never needs to outlive it.
const AccessTokenTTL = 15 * time.Minute
type Revocations struct {
redis *redis.Client
}
func NewRevocations(client *redis.Client) *Revocations {
return &Revocations{redis: client}
}
func epochKey(userID string) string { return "revoked-before:" + userID }
// RevokeAllBefore marks every access token issued before at as unusable.
// One SET replaces what a per-token denylist would need N writes to express,
// and the TTL guarantees the key disappears once it cannot matter anymore.
func (r *Revocations) RevokeAllBefore(ctx context.Context, userID string, at time.Time) error {
return r.redis.Set(ctx, epochKey(userID), at.Unix(), AccessTokenTTL).Err()
}
// IsRevoked answers the middleware's question with one round trip. The jti
// denylist built in the Authentication phase still handles single-token
// logout; this adds the "everything for this user" case on top of it.
func (r *Revocations) IsRevoked(ctx context.Context, userID string, issuedAt time.Time) (bool, error) {
raw, err := r.redis.Get(ctx, epochKey(userID)).Result()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
epoch, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return false, err
}
return !issuedAt.After(time.Unix(epoch, 0)), nil
}Note the comparison direction: a token whose iat equals the epoch second is rejected, not accepted. Revocation and issuance landing in the same second is exactly the race an attacker would want to win, and rounding in their favour would hand it to them.
Termination Calls the Owner, It Does Not Write Redis Directly
It is tempting to let staff-service call RevokeAllBefore itself — the Redis client is right there. Resist it. Auth-service owns credential state, and a second writer means two services must agree forever on key format, TTL, and clock source. Staff-service instead calls an internal endpoint, and auth-service performs both halves of the revocation it already knows how to do: kill the refresh family in Postgres, then set the epoch in Redis.
-- user_id is a logical reference to user-service, not a cross-database FK.
ALTER TABLE staff ADD COLUMN user_id uuid;
CREATE UNIQUE INDEX staff_user_id_key
ON staff (organization_id, user_id)
WHERE user_id IS NOT NULL;A staff row and a login identity are different records with different IDs. user_id remains nullable because cleaners and vendors may never receive an account; the staff-account linking command validates non-null values through User Service before storing the logical reference.
type Staff struct {
ID uuid.UUID
OrganizationID uuid.UUID
+ UserID *uuid.UUID
}
-SELECT id, organization_id, status
+SELECT id, organization_id, user_id, status
FROM staff
WHERE organization_id = $1 AND id = $2 func (s *Service) Terminate(ctx context.Context, staffID uuid.UUID, actor Actor) error {
+ staff, err := s.repo.FindByID(ctx, actor.OrgID, staffID)
+ if err != nil {
+ return err
+ }
if err := s.repo.MarkTerminated(ctx, actor.OrgID, staffID, s.now()); err != nil {
return err
}
+ if staff.UserID == nil {
+ return nil // This staff member never had a platform login to revoke.
+ }
+ // Best-effort ordering: the staff record is already terminated, so a
+ // revocation failure must be loud rather than silently swallowed.
+ if err := s.auth.RevokeUser(ctx, *staff.UserID); err != nil {
+ return fmt.Errorf("staff: terminated but session revocation failed: %w", err)
+ }
return nil
}The error wrapping matters more than the call. If revocation fails after the staff row is already terminated, the operator must see a failure that names both facts — otherwise the UI reports success while the guard's badge app keeps working.
package httpapi
// RevokeUser is called by staff-service on termination and by the security
// team's break-glass tooling. It closes both halves of the gap: the refresh
// family stops renewal, and the epoch invalidates tokens already issued.
func (h *Handler) RevokeUser(c *gin.Context) {
userID := c.Param("user_id")
if err := h.families.RevokeAllForUser(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "family revocation failed"})
return
}
if err := h.revocations.RevokeAllBefore(c.Request.Context(), userID, time.Now().UTC()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "epoch revocation failed"})
return
}
c.Status(http.StatusNoContent)
}Postgres first, Redis second. If the order were reversed and the database write failed, the epoch would expire in fifteen minutes and the terminated user would silently regain the ability to refresh.
The Check Belongs in the Middleware That Already Verifies Signatures
claims, err := verifier.Verify(raw)
if err != nil {
unauthorized(w, "invalid_token")
return
}
+revoked, err := revocations.IsRevoked(r.Context(), claims.Subject, claims.IssuedAt)
+if err != nil {
+ // Fail closed: an unreachable Redis must not silently disable revocation.
+ serverError(w, "revocation_check_failed")
+ return
+}
+if revoked {
+ unauthorized(w, "session_revoked")
+ return
+}
next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), claims)))Signature verification stays exactly where the Authentication phase put it; the new lookup runs after it, so an unauthenticated flood never reaches Redis.
The tempting fallback during a Redis outage is to trust the signature alone and carry on serving. That converts a cache dependency into a security control that switches itself off precisely when nobody is watching dashboards. Fail closed, alert on the error rate, and accept that revocation availability is now part of the platform's availability budget.
Confirm a Terminated Staff Member's Session Dies Now, Not Later
| Revocation need | Mechanism | Where it lives | Why not the others |
|---|---|---|---|
| One device logs out | jti denylist entry | auth-service, Redis, TTL = token lifetime | An epoch would sign every other device out too |
| Stop renewal after password reset | Refresh-token family revocation | auth-service, Postgres | Redis alone forgets; family state must survive a restart |
| Terminate a staff member now | Revocation epoch for the user | auth-service, Redis, one key | The outstanding jti values are unknown and unenumerable |
Applied exercise
Decide what the epoch costs when it is wrong
A security engineer proposes setting the revocation epoch to now() for every user in an org after a suspected credential-stuffing wave, rather than for individual accounts.
- Estimate the Redis memory cost of one epoch key per user for an org with 4,000 residents, and compare it with a jti denylist entry per outstanding token.
- Describe what residents experience in the first fifteen minutes after the bulk epoch is set, and which endpoint absorbs the resulting load.
- Decide whether the epoch write should be one pipeline or batched, and justify the choice against the Redis latency budget from the cache-aside lesson.
- State the rollback: what happens if the epoch was set in error, and why deleting the keys is or is not sufficient.
Deliverable
A short written decision covering memory, retry load, batching, and rollback, with the numbers you used.
Completion checks
- The memory comparison names both the per-user and per-token growth rates, not just one.
- The rollback answer accounts for tokens already rejected — clients will have discarded them.
Why does forced revocation use a per-user epoch timestamp rather than adding each of the user's access tokens to the jti denylist?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.