Revocation & Security
Revocation & Security
How tokens and sessions are revoked, how attacks are detected, and how core.auth responds to security events.
Revocation Strategies
core.auth implements multiple layers of revocation, each covering different scenarios:
| Strategy | Mechanism | Latency | Use Case |
| Short expiry | JWT expires in 15 min | 15 min window if stolen | Default protection |
| Session deletion | Delete/revoke session row | Immediate on next refresh | Logout, admin action |
| Blacklist | Token JTI in blacklist table | Immediate for advanced scopes | Mid-window revocation of sensitive ops |
| Explicit logout | User logs out → session killed | Immediate | User-initiated |
| Global kill | "Log out everywhere" → all sessions killed | Immediate | Emergency / compromise |
How They Layer
JWT stolen:
└── Attacker has up to 15 min window (short expiry)
├── No way to revoke mid-window for simple scopes
│ └── After expiry, token is worthless
└── For advanced scopes: blacklist check catches it
└── Attacker blocked even within the window
Session compromised:
└── Admin/user deletes session row
└── Next refresh fails → attacker locked out within 15 min maxSession Revocation
The primary revocation mechanism. Deleting a session row is the nuclear option for that session — the JWT becomes worthless as soon as it expires.
Single Session Logout
User clicks "Log out" in todo-app
│
▼
App calls core.console: POST /v1/auth/session/logout
├── JWT access token (to identify the session via sid claim)
└── session_ref cookie (sent automatically)
│
▼
core.auth:
├── Reads session ID from JWT payload (sid) or cookie
├── Revokes the session row (SET revoked_at = NOW(), revoke_reason = 'logout')
├── Clears session_ref cookie
├── Logs logout event
└── Returns success
│
▼
App clears local JWT
│
▼
User is logged out of todo-app
(User still has sessions on other apps)Global Logout ("Log Out Everywhere")
User clicks "Log out everywhere" in account dashboard
│
▼
Account dashboard calls: POST /session/logout-all
├── JWT access token
└── password (re-verification)
│
▼
core.auth:
├── Revokes ALL sessions for this user
├── Logs global logout event
└── Returns success
│
▼
All JWTs expire within 15 minutes
User must re-authenticate everywhereAdmin Revocation
An admin can revoke any user's sessions:
POST /admin/sessions/revoke
├── Admin JWT (with admin scope)
├── target_user_id
└── reason: "admin_action"
│
▼
core.auth revokes all sessions for the target userBlacklist (Advanced Scopes)
For advanced-scope operations, core.auth checks a token blacklist in addition to JWT signature and expiry. This handles the 15-minute window between token issuance and expiry for sensitive operations.
Table
CREATE TABLE token_blacklist (
id SERIAL PRIMARY KEY,
token_jti VARCHAR(64) UNIQUE NOT NULL, -- unique token identifier (sid from JWT)
user_id UUID NOT NULL REFERENCES users(id),
reason VARCHAR(50) NOT NULL, -- logout, compromise, admin
ip_address INET,
blacklisted_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL -- auto-cleanup after JWT TTL
);When Tokens Are Blacklisted
| Trigger | Reason | Scope Affected |
| User logs out | logout | Current session's token |
| Admin action | admin | All user's tokens |
| User reports compromise | compromise | All user's tokens |
Performance Consideration
The blacklist is checked only for advanced and critical scopes, not for simple scopes. This keeps performance high for routine requests (profile reads, app access) while adding a security layer for sensitive operations (email changes, data exports).
Since JWTs are short-lived (15 min), blacklist entries only need to persist for the maximum token lifetime. A cleanup job removes expired entries:
DELETE FROM token_blacklist WHERE expires_at < NOW();Blacklist Check Flow
App request with advanced scope
│
▼
Verify JWT signature → valid
│
▼
Check exp claim → not expired
│
▼
Check blacklist: SELECT 1 FROM token_blacklist WHERE token_jti = ?
│
├── Not found → proceed (token is clean)
└── Found → 401 (token is blacklisted)The
token_jti used for the blacklist is the session ID (sid) from the JWT payload. This means blacklisting a session ID immediately invalidates the JWT for advanced/critical operations, even within its 15-minute window.IP Logging
All authentication events are logged with IP address for audit and anomaly detection.
What Gets Logged
| Event | event_type | Additional Data |
| Successful login | login | IP, user agent, device fingerprint, geo |
| Failed login | login_failed | IP, user agent, reason (wrong password, 2fa fail) |
| Token refresh | refresh | IP, user agent |
| Logout | logout | IP, user agent |
| Password change | password_change | IP, user agent |
| Email change | email_change | IP, user agent, old + new email |
| 2FA enabled/disabled | 2fa_change | IP, user agent |
| Session revoked | session_revoked | IP, user agent, reason |
How This Data Is Used
- "Recent activity" view — users can see their login history in the account dashboard
- "New device" alerts — email notification when a login happens from a new device or location
- Anomaly detection — flag rapid IP changes, unusual geolocations, multiple failed logins
- Security audit — investigate incidents by tracing IP/user agent history
- Rate limiting — block IPs with excessive failed login attempts
Retention
ident_events accumulate fast. Retention policy:
- Last 30 days — always available, shown in "recent activity"
- 30–90 days — available for security audit, not shown in UI
- 90+ days — pruned by cleanup job (configurable)
DELETE FROM ident_events
WHERE created_at < NOW() - INTERVAL '90 days'
AND event_type NOT IN ('login_failed');Security-critical events (failed logins) may be retained longer.
Anomaly Detection on Refresh
Without refresh token rotation, stolen-token detection relies on signals at the refresh endpoint. The approach is conservative: log everything, act only on strong signals, never punish users for normal network behavior.
Users switch networks constantly — WiFi → 5G, home → office, VPN on/off, traveling. IP changes and geo jumps are normal. Revoking sessions on every network hop would make the app unusable.
What Gets Logged (Always)
Every refresh request logs: IP, user agent, geo (if available), timestamp. This data feeds the "recent activity" view and is available for security audits. It does not trigger automatic revocation.
What Triggers Action
| Signal | Strength | Action |
| User agent changed | Strong — browser/OS doesn't change mid-session | Flag, log, notify user |
| Rate limit exceeded | Strong — 10+ refreshes in 5 min | Temporary IP block, log |
| IP change | Weak — normal behavior | Log only, no action |
| Geo jump | Weak — unreliable, VPNs, mobile networks | Log only, no action |
Why Only User Agent?
The user agent is the one thing that shouldn't change during a legitimate session. A user can switch from WiFi to 5G (IP change), travel between cities (geo jump), or turn on a VPN (both). But they won't spontaneously switch from Chrome on Windows to Safari on macOS.
A user agent mismatch on refresh is a strong signal that the session cookie has been stolen and is being used from a different device.
Response to User Agent Mismatch
POST /session/refresh
│
▼
core.auth looks up session row
│
▼
Session found — compare user agents:
├── Session origin: Chrome 125, Windows
├── Current request: Safari 19, macOS
└── MISMATCH
│
▼
1. Log security event (ident_events, event_type: 'user_agent_mismatch')
2. Notify user via email: "New device detected on your session"
3. DO NOT revoke — let the user decide
(they may have legitimately switched devices)
4. Issue JWT as normalThe notification lets the user decide: "Was this you?" If not, they can revoke the session from the account dashboard. This avoids false positives (user switched browsers) while still alerting on genuine compromises.
Why Not Auto-Revoke?
Auto-revocation on anomaly sounds secure but causes real problems:
- User switches from phone browser to phone app → user agent changes → locked out
- VPN connects/disconnects → IP jumps → locked out
- Mobile carrier changes IP range → geo jumps → locked out
The security gain from auto-revocation is marginal. The primary protection layers are:
- Short JWT TTL (15 min) — stolen token expires fast
- Rate limiting — brute-force blocked
- HttpOnly cookie — XSS can't steal the session ref
- Hard session cap (30 days) — abandoned sessions expire
- User notification — user can self-revoke if something looks wrong
Anomaly detection is an early warning system, not a kill switch.
Future: Adaptive Scoring
When the userbase grows, the anomaly detection can evolve into a risk scoring system:
risk_score = 0
risk_score += ip_changed ? 1 : 0
risk_score += geo_changed ? 1 : 0
risk_score += user_agent_changed ? 3 : 0
risk_score += rapid_refresh ? 2 : 0
if risk_score >= 5: require_re_auth()
elif risk_score >= 3: notify_user()
else: log_and_proceed()This lets you combine multiple weak signals into a strong one without punishing any single normal behavior. But for now, user agent mismatch + rate limiting is sufficient.
Rate Limiting
core.auth applies rate limiting at multiple levels:
| Endpoint | Limit | Window | Action |
/api/login | 5 attempts | 15 min | Lock account + notify |
/session/refresh | 10 attempts | 5 min | Temporary IP block |
/api/verify-2fa | 3 attempts | 5 min | Lock 2FA + require recovery code |
Rate limits are per-IP and per-user. Exceeding limits triggers:
- Temporary block (HTTP 429)
- Event logging
- User notification (for login attempts)
Next: SSO Flow — cross-app login and navigation.