Auth tokens
JWT access tokens + opaque rotating refresh tokens. Family revocation on refresh-token reuse detection.
Purpose
Authenticate API calls from the Chrome extension (and future clients) without session cookies. Token lifetimes limit exposure from theft; rotation + reuse detection catches replayed tokens.
Contract reference: AUTH.md.
Implementation
Access token (JWT)
- Algorithm: HS256 (symmetric HMAC, simple to operate).
- Lifetime:
JWT_ACCESS_TTL_SECONDS(default 900 = 15 minutes). - Claims:
{
"sub": "<user._id>",
"permissions": ["content.submit", "content.approve"],
"iat": 1713999999,
"exp": 1714000899,
"jti": "tok_01H…" // ULID for logging / future revocation lists
}
Permissions are embedded at mint time. The guard doesn't hit the database on every request — the JWT is trusted until expiry.
Staleness tradeoff. If an admin revokes a permission mid-token-lifetime, the user keeps it for up to 15 minutes. Acceptable. For immediate revocation, a blocklist on jti could be added later; v1 accepts the window.
Refresh token (opaque)
- 32 random bytes,
base64url-encoded. 256 bits of entropy — no brute force concern. - Stored hashed (SHA-256) server-side in
refresh_tokens:
@Schema({ timestamps: true, collection: 'refresh_tokens' })
class RefreshToken {
@Prop({ required: true, ref: 'User', index: true }) userId: Types.ObjectId;
@Prop({ required: true, unique: true, index: true }) tokenHash: string; // SHA-256
@Prop({ required: true, index: true }) familyId: string; // groups rotations of a single login
@Prop({ required: true }) expiresAt: Date;
@Prop() revokedAt?: Date;
@Prop() replacedBy?: string; // tokenHash of the successor
@Prop() userAgent?: string;
@Prop() createdIp?: string;
}
Indexes: { tokenHash: 1 } unique, { userId: 1, createdAt: -1 }, { familyId: 1 }, { expiresAt: 1 } (TTL index auto-deletes expired rows).
Login flow
POST /auth/login { email, password }
→ validate credentials (argon2 verify)
→ mint access token (15m)
→ mint refresh token (30d) with new familyId = ulid()
→ insert refresh_tokens row: { userId, tokenHash, familyId, expiresAt, userAgent, createdIp }
→ return { accessToken, refreshToken, user }
Failed login: always returns the same error (auth.invalid_credentials) regardless of whether the email exists, to prevent enumeration.
Refresh flow (rotation + reuse detection)
POST /auth/refresh { refreshToken }
→ hash the token
→ lookup by tokenHash
→ NOT FOUND → 401 auth.invalid_token
→ FOUND:
if row.revokedAt is set:
// Reuse detected. Someone rotated this token before, and now a stale copy is being presented.
// The attacker has one half of a family; we revoke everything.
db.refresh_tokens.updateMany({ familyId: row.familyId }, { $set: { revokedAt: now } })
log error 'auth.refresh.reused' with userId + familyId
return 401 auth.invalid_token
if row.expiresAt < now:
return 401 auth.invalid_token
// Happy path: rotate
const next = randomBase64Url(32)
const nextHash = sha256(next)
row.revokedAt = now
row.replacedBy = nextHash
await row.save()
await db.refresh_tokens.create({ userId: row.userId, tokenHash: nextHash, familyId: row.familyId, expiresAt: now + 30d, ... })
return { accessToken: mintAccess(row.userId), refreshToken: next }
The reuse detection is the critical security property. If a token appears twice, one of:
- A legitimate client replayed a cached request (rare — clients always discard rotated tokens).
- An attacker has a stolen refresh token AND the legitimate user also has a live one. Either one will trigger reuse next time.
Either way, the family is revoked and the user is forced to re-login. False positives cost one login. That's the right tradeoff.
Logout
POST /auth/logout (authenticated)
→ body.refreshToken (the client's current one)
→ hash, find row, set revokedAt = now on the entire familyId
→ return { success: true, data: null }
Family revocation on logout (not just the single row) closes any replayed copies too.
Password change
POST /auth/password/change (authenticated) { currentPassword, newPassword }
→ verify currentPassword
→ hash newPassword (argon2)
→ update User
→ revoke all refresh tokens for this user (not just current family)
→ return success
Access tokens outlive the password change by up to 15 minutes — acceptable for most flows; add a tokenVersion on User for immediate invalidation only if the threat model demands it.
Required variables and services
| Env | Purpose |
|---|---|
JWT_ACCESS_SECRET | Access-token HMAC secret (32+ random bytes) |
JWT_REFRESH_SECRET | Reserved; current design uses opaque tokens rather than JWTs for refresh. Keep the env for future use. |
JWT_ACCESS_TTL_SECONDS | Default 900 |
JWT_REFRESH_TTL_SECONDS | Default 2592000 (30 days) |
Services:
UsersService— used byAuthService.loginto verify credentials.RefreshTokenRepository— dedicated service wrapping therefresh_tokenscollection so the rotation logic isn't scattered.ArgonService(thin wrapper overargon2) — centralizes hash params so operational changes are one-liners.
Gotchas
- Never sign a JWT with a short secret.
JWT_ACCESS_SECRET< 32 bytes makes HS256 breakable. Fail startup with a clear error if too short. - Hashed refresh tokens are not reversible. On reuse-detection we know the
familyId(it's on the row we found), but we can't "which IP was using the good token vs the bad one" — log the presenter's IP on every refresh and retain in the row's audit trail to aid investigation. - Family ID is a login identifier, not a user identifier. One user with concurrent logins on phone + desktop has two families. Logging out on desktop doesn't log out on phone.
- Clock skew. JWT libraries have a default
clockToleranceof 0. Small positive tolerance (e.g. 5s) avoids edge-of-second expiries during rotation. Don't go above 30s. - No rate limit on
/auth/refresh? Yes, there is — 60/hr/IP. A legit Chrome extension refreshes roughly every 15 minutes (every expired access token triggers one). 60/hr is generous.
Testing
- Unit:
ArgonService.hash→verifyroundtrip. - Unit:
RefreshTokenRepository.rotate— inject a fixture row, call rotate, assert new row exists, old is revoked. - Integration: full login → refresh → refresh → refresh flow works indefinitely.
- Integration (reuse detection): login, capture token T0; refresh to T1; refresh T0 again (stale). Assert 401, assert T1 is also revoked (entire family), assert log line at error level with code
auth.refresh.reused. - Integration (logout): login, logout, attempt refresh — 401.
- Integration (password change): login, change password, prior refresh token is revoked.
- Integration (expired access): mint a 1-second access token, wait 2 seconds, call protected endpoint, assert 401
auth.invalid_token.