Skip to main content

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:

  1. A legitimate client replayed a cached request (rare — clients always discard rotated tokens).
  2. 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

EnvPurpose
JWT_ACCESS_SECRETAccess-token HMAC secret (32+ random bytes)
JWT_REFRESH_SECRETReserved; current design uses opaque tokens rather than JWTs for refresh. Keep the env for future use.
JWT_ACCESS_TTL_SECONDSDefault 900
JWT_REFRESH_TTL_SECONDSDefault 2592000 (30 days)

Services:

  • UsersService — used by AuthService.login to verify credentials.
  • RefreshTokenRepository — dedicated service wrapping the refresh_tokens collection so the rotation logic isn't scattered.
  • ArgonService (thin wrapper over argon2) — 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 clockTolerance of 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.hashverify roundtrip.
  • 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.