Auth / JWT

JWT & Tokens

Open Astra signs all tokens with HS256 via the jose library. This page covers the signing/verification internals, dual-key rotation, device fingerprinting, and refresh token storage.

Token signing

text
// Access tokens
signAccessToken(uid: string, fingerprint?: string): Promise<string>
  → HS256 signed with JWT_SECRET
  → Claims: { uid, sub, iat, exp, dfp? }
  → TTL: JWT_ACCESS_EXPIRES (default "15m")

// Refresh tokens
signRefreshToken(uid: string): Promise<string>
  → HS256 signed with JWT_SECRET
  → Claims: { uid, type: "refresh", sub, iat, exp }
  → TTL: JWT_REFRESH_EXPIRES (default "7d")

Verification flow

Both access and refresh token verification try the current secret first, then the previous secret (if configured). This enables zero-downtime key rotation.

text
verifyAccessToken(token, fingerprint?):
  1. Try JWT_SECRET
  2. If fails AND JWT_SECRET_PREV is set → try JWT_SECRET_PREV
  3. If fingerprint provided → validate dfp claim matches
  4. Return { uid } on success, throw AuthError on failure

verifyRefreshToken(token):
  1. Same dual-key verification
  2. Assert type === "refresh" (prevents cross-use)
  3. Return { uid }

Device fingerprinting

When BIND_JWT_TO_DEVICE=true, tokens include a dfp claim that ties them to a specific user agent and IP subnet. This mitigates token theft — a stolen token cannot be used from a different device or network.

typescript
// Device fingerprint = first 16 hex chars of SHA-256
computeFingerprint(userAgent: string, ipSubnet: string): string

// ipSubnet = first 3 octets of client IP (e.g., "192.168.1")
// Prevents token reuse across devices/networks while allowing
// IP changes within the same subnet (mobile, DHCP, etc.)
Subnet-level binding. The fingerprint uses the first 3 octets of the IP (e.g., 192.168.1), not the full IP. This prevents false rejections from DHCP reassignment or mobile network changes within the same subnet.

Key rotation

Rotate your JWT signing key with zero downtime using the dual-key mechanism.

bash
# Step 1: Set the new secret and keep the old one
JWT_SECRET=new-secret-key
JWT_SECRET_PREV=old-secret-key

# Step 2: Restart the gateway
# All existing tokens (signed with old key) remain valid
# New tokens are signed with the new key

# Step 3: After all old access tokens expire (15m default),
# remove JWT_SECRET_PREV
JWT_SECRET=new-secret-key
# JWT_SECRET_PREV removed

# Note: Refresh tokens may be valid for up to 7 days.
# Keep JWT_SECRET_PREV set for 7 days for a fully clean rotation.

Refresh token storage

Refresh tokens are never stored in plaintext. The database holds a SHA-256 hash — even a full database leak doesn't expose usable tokens.

text
// Refresh tokens stored as SHA-256 hex in refresh_tokens table
hashRefreshToken(token: string): string
  → createHash('sha256').update(token).digest('hex')

// Table: refresh_tokens
// Columns: id, user_id, token_hash, expires_at, revoked, created_at
// On refresh: old row marked revoked=true, new row inserted
// Cleanup: daily cron deletes WHERE expires_at < NOW() OR revoked = true

Token revocation

Access tokens can be revoked individually (by JTI) or in bulk (all tokens for a user). Revocation is checked on every request — if the database is unreachable, the check fails closed (token is rejected).

text
// Every access token gets a unique jti (JWT ID) claim
signAccessToken(uid):
  → jti = randomUUID()
  → Claims: { uid, sub, jti, iat, exp, dfp? }

// On verification, the jti is checked against revoked_tokens
verifyAccessToken(token):
  → decode token, extract jti
  → SELECT jti FROM revoked_tokens WHERE jti = $jti
  → also checks "all:<uid>" sentinel for bulk revocation
  → FAIL-CLOSED: if DB query fails, token is rejected

// Revoke a single token
revokeToken(jti, exp, uid?):
  → INSERT INTO revoked_tokens (jti, uid, exp)
  → ON CONFLICT (jti) DO NOTHING

// Revoke ALL tokens for a user (password change, account lock)
revokeAllAccessTokens(uid):
  → INSERT jti = "all:<uid>" into revoked_tokens
  → verifyAccessToken always checks this sentinel

// Cleanup: daily cron at 3:15 AM prunes expired rows
pruneExpiredRevocations():
  → DELETE FROM revoked_tokens WHERE exp < NOW()
Fail-closed. Unlike most middleware that degrades gracefully on DB failure, token revocation rejects the request if it cannot verify the token's revocation status. This prevents revoked tokens from being used during a database outage.

Middleware integration

The jwtAuth() middleware (step 7 in the middleware stack) calls verifyAccessToken on every authenticated request. It extracts the uid and attaches it to req.uid for downstream handlers. WebSocket connections authenticate via a ?token= query parameter on the initial upgrade request.