Skip to main content
GitHat

GitHat Security Overview: Threat Model and Mechanisms

Verified threat model covering RS256-via-KMS signing, tokenVersion session invalidation, CSRF double-submit, app-key SHA-256 hashing, per-tenant isolation, and MFA — grounded in the actual source.

Token Architecture

GitHat issues five distinct JWT classes. Each class has its own signing secret so a compromise of one class does not cascade to others. User access and refresh tokens are signed with RS256 via AWS KMS (see below). MCP, agent, and Eliza tokens remain HS256, each backed by a separate secret (GITHAT_MCP_SECRET, GITHAT_AGENT_SECRET, GITHAT_ELIZA_SECRET). MFA challenge tickets use a sixth secret (MFA_TICKET_SECRET).

Token lifetimes: user access = 15 minutes, refresh = 7 days, MCP = 5 minutes, agent = 2 minutes, Eliza = 5 minutes, MFA ticket = 5 minutes.

Every token carries: iss (enforced as https://api.githat.io or JWT_ISSUER env var), aud (the app UUID / appId, preventing cross-app replay), jti (16-byte random hex for audit correlation), iat, exp, and nbf set 30 seconds in the past to absorb client clock skew. Source: src/lib/jwt.js lines 99-153, 165-179.

RS256 via AWS KMS

User access and refresh tokens are signed by calling kms:Sign (RSASSA_PKCS1_V1_5_SHA_256, MessageType=DIGEST) against a KMS RSA-2048 asymmetric key identified by the env var GITHAT_RS256_KMS_KEY_ID. The private key material never leaves the KMS HSM — Lambda never holds it.

On cold start, jwt-rs256.js calls kms:GetPublicKey once, converts the DER-encoded SPKI response to PEM, imports it via jose.importSPKI, and caches the CryptoKey for the lifetime of the container. Verification (jwt-rs256.verify) is pure local crypto via jose.jwtVerify — no KMS round-trip on the hot path. Cost is bounded by issuance volume, not request volume.

The module computes the RFC 7638 JWK thumbprint (SHA-256 of the canonical public JWK) and embeds it as the kid header in every token. The public JWK is published at /.well-known/jwks.json; consumers verify locally. The module is activated solely by the presence of GITHAT_RS256_KMS_KEY_ID — if the env var is absent, sign() returns null and generateAccessToken throws immediately rather than silently falling back to HS256.

The verifyToken function in jwt.js reads the alg header from the unverified JWT, routes RS256 tokens through rs256.verify and HS256 tokens through jsonwebtoken.verify with algorithms: ['HS256']. The none algorithm and any unknown algorithm cause an immediate throw. As a defense-in-depth step, the verified payload's type and tokenType claims are compared against the unverified header's claims; a mismatch is rejected. Source: src/lib/jwt-rs256.js lines 1-287, src/lib/jwt.js lines 265-341.

Sign path (one KMS call per issuance):
  JWT header.payload → SHA-256 digest → kms:Sign (RSASSA_PKCS1_V1_5_SHA_256) → base64url signature

Verify path (no KMS call, local):
  jose.jwtVerify(token, cachedPublicKey, { issuer, algorithms: ['RS256'] })

Secrets Storage and Rotation

In production (AWS_LAMBDA_FUNCTION_NAME set), all six HS256 secrets and the MFA trusted-device HMAC secret are stored in AWS Secrets Manager at paths /githat/prod/<logicalName>. secrets-mgr.js fetches them concurrently at handler cold-start via ensureSecretsLoaded() and holds a 5-minute in-memory cache. Rotation is an aws secretsmanager put-secret-value operation — no redeploy required; propagation is bounded by one TTL (~5 minutes).

The logical names and their env-var fallbacks are: access (GITHAT_ACCESS_SECRET), refresh (GITHAT_REFRESH_SECRET), mcp (GITHAT_MCP_SECRET), agent (GITHAT_AGENT_SECRET), eliza (GITHAT_ELIZA_SECRET), mfa_ticket (MFA_TICKET_SECRET), mfa_trusted_device (MFA_TRUSTED_DEVICE_SECRET). RS256 signing is intentionally absent from this list — there is no private key in Secrets Manager; KMS holds the bytes.

The getSecretSync() path used by jwt.js sign/verify returns the cached value when fresh and falls back to the env var directly. If Secrets Manager is unreachable and a env fallback is set, the service stays up and emits a CRITICAL log. If neither is available, the call throws. Source: src/lib/secrets-mgr.js lines 50-206.

tokenVersion Session Invalidation

Each user record in DynamoDB carries a tokenVersion (integer, defaults to 0). Every user access token carries the claim tv set to that value at issuance time. requireAuth (src/middleware/auth.js lines 67-107) fetches the live user record after signature verification and compares decoded.tv to user.tokenVersion ?? 0. A mismatch causes a 401 (Session invalidated — please log in again) without requiring a full-table scan or a token blacklist.

tokenVersion is incremented on: password change, MFA enable/disable, logout-all / revoke-all-sessions, and refresh-token rotation violation (detected reuse). Because the access token TTL is 15 minutes, a version bump invalidates all outstanding access tokens within at most 15 minutes; refresh tokens (7-day TTL) become unable to reissue new access tokens as soon as the refresh handler performs the same tv check.

The middleware enforces strict equality — there is no carve-out for tokens without a tv claim. A live access token lacking tv is treated as forged or bugged and rejected.

Cookie Transport and the __Host- Prefix

When a browser client requests cookie-mode login (?setCookie=true), the server sets HttpOnly, Secure, SameSite=Lax, Path=/ cookies. In production (NODE_ENV=production), the cookies are named __Host-githat_access and __Host-githat_refresh. The __Host- prefix is a browser-enforced constraint: it requires Secure=true, Path=/, and no Domain= attribute, blocking sibling-subdomain cookie injection. In dev, the names are githat_access and githat_refresh (no prefix).

The CSRF cookie (githat_csrf) is NOT HttpOnly — JavaScript must read it. It is Secure, SameSite=Strict, Path=/, Max-Age=604800. No Domain= attribute.

On logout, all four names (both current and legacy) are cleared, plus githat_csrf and the MFA trusted-device cookie. Source: src/lib/response.js lines 126-234.

CSRF: Double-Submit Cookie

All mutating requests (POST, PUT, PATCH, DELETE) that use cookie-based auth are protected by a double-submit cookie. On login or refresh, the server generates a 32-byte (256-bit) cryptographically random token via crypto.randomBytes(32).toString('hex') and sets it in the githat_csrf cookie (non-HttpOnly). The client must echo this value in the X-CSRF-Token (or x-csrf-token) header on every mutating request.

validateCsrf() in src/lib/csrf.js reads both values and compares them with crypto.timingSafeEqual to prevent timing attacks. If the lengths differ, it short-circuits with 'CSRF token invalid' before reaching the timing-safe compare.

Two bypass conditions exist: (1) safe HTTP methods (GET, HEAD, OPTIONS) are skipped per RFC 7231; (2) requests that carry an Authorization: Bearer <token> header AND have no auth cookie present are skipped, because a stateless API client with no cookie cannot be victimized by cross-site request forgery. If the auth cookie is present alongside a Bearer header, CSRF is enforced — an attacker can add a Bearer header cross-site but cannot forge the HttpOnly session cookie. Source: src/lib/csrf.js lines 80-127.

POST /auth/logout HTTP/1.1
Authorization: Bearer <omit if using cookie mode>
Cookie: __Host-githat_access=<token>; githat_csrf=<32-byte-hex>
X-CSRF-Token: <same 32-byte-hex>

App-Key Hashing (SHA-256, Never Stored Raw)

Consumer apps receive publishable keys prefixed pk_live_ and secret keys prefixed sk_live_. Raw key values are never written to DynamoDB. The createApiKey() function in src/lib/dynamo.js explicitly strips the key field ({ key: _raw, ...safeKey }) before the PutCommand. Only keyHash (SHA-256(rawKey), hex-encoded) and keyPrefix (first 14 characters of the raw key, shown in the dashboard) are stored.

On each request carrying X-GitHat-App-Key, getApiKeyByValue() computes SHA-256(rawKey) and queries a DynamoDB GSI named KeyHashIndex on the keyHash attribute. Results are cached in a 256-entry in-memory LRU with a 60-second TTL, keyed by hash — the raw key value is never stored in the cache.

sk_live_ keys are for server-to-server use and should never appear in CORS-resolved requests (which handle only pk_live_ keys). Source: src/lib/dynamo.js lines 1114-1191.

Per-Tenant CORS Isolation

Every Lambda response defaults its CORS origin to https://githat.io (setCorsOrigin('https://githat.io') in initCors). A more permissive origin is set only when the request can be attributed to a registered tenant.

Resolution has three paths: (1) if X-GitHat-App-Key (pk_live_...) is present, getApiKeyByValue() resolves the key to a DynamoDB App record; the origin hostname is then matched against app.domain and app.customDomain (apex and www. variants tolerated via _hostMatchesApp). If they match, the origin is echoed back; otherwise the default stands. (2) If no app key is present (typical for CORS preflight OPTIONS, which cannot carry custom headers), the origin hostname is looked up against a DomainIndex GSI, with a 60-second per-container cache. (3) Localhost origins bypass key validation only in non-production environments (NODE_ENV !== 'production'); JWT auth still applies on all endpoints in this path.

The per-invocation origin is bound via AsyncLocalStorage in response.js so concurrent warm-container invocations cannot leak each other's origin header. Source: src/lib/cors.js.

MFA: TOTP and Recovery Codes

MFA enrollment uses RFC 6238 TOTP (HMAC-SHA1, 6 digits, 30-second window) via the otpauth library. The server generates a 20-byte (160-bit) base32 secret, stores it as mfaSecretPending until the user proves code readability via /auth/2fa/enable, then promotes it to mfaSecret. The secret is presented as an otpauth:// URI and a QR code (base64 PNG).

verifyTotp() normalizes the submitted code (strips whitespace and hyphens), rejects anything not matching /^\d{6}$/, and calls totp.validate({ token, window: 1 }) — a ±1 step (±30 second) window to tolerate client clock skew.

When a user with MFA enabled submits their password at /auth/login, the server verifies the password, then issues a 5-minute MFA ticket (signed with MFA_TICKET_SECRET, separate from the access/refresh secrets) instead of a full session. The ticket contains userId and sessionMeta (userAgent, ipAddress, authProvider). The client presents the ticket plus a TOTP code to /auth/2fa/login-verify to complete sign-in.

Ten recovery codes are generated at enrollment time as 8-4-8 base64url strings (XXXXXXXX-XXXXXXXX format), bcrypt-hashed (10 rounds) and stored as an array of { hash, usedAt }. Plaintext is shown to the user exactly once. Used codes are skipped on verification to avoid redundant bcrypt work. Source: src/lib/mfa.js, src/lib/jwt.js lines 409-464.

MFA Trusted-Device Cookie (8-Hour MFA Skip)

After a successful MFA verification, the server may issue an __Host-githat_mfa_trusted cookie (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=28800). The cookie value is <userId>.<expiresAtMs>.<HMAC-SHA256(userId+'.'+expiresAtMs, MFA_TRUSTED_DEVICE_SECRET)>, base64url-encoded.

On subsequent logins within the 8-hour window, the server verifies the HMAC via crypto.timingSafeEqual before skipping the MFA challenge. The HMAC secret (MFA_TRUSTED_DEVICE_SECRET) is stored separately from JWT secrets in Secrets Manager; a compromise only weakens the skip-pad, not token forgery.

The cookie is cleared on logout, password reset, MFA disable, and account deletion via buildClearMfaTrustedCookie(). There is currently no revocation list (jti tracking) for this cookie — that is a documented future hardening. Source: src/lib/mfa-trusted-device.js.

Password Hashing and Timing Defenses

Passwords are hashed with bcrypt (bcryptjs) at cost factor 12 (SALT_ROUNDS = 12). The password policy requires 8-72 characters with at least one uppercase, one lowercase, one digit, and one special character. The 72-character maximum is the bcrypt input-length limit in bcryptjs.

A timing-balance hash is computed once at module load by bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12). Login handlers run bcrypt.compare() against this hash on the user-not-found branch so the response time is indistinguishable from a valid-email/wrong-password path.

The codebase also includes isArgon2Hash() detection and a verifyPasswordWithRehash path, but argon2id verification is currently disabled (the argon2 native module was reverted due to Lambda bundling constraints). Users with argon2id hashes in the database cannot verify and will need a password reset until the module is restored via a Lambda Layer. Source: src/lib/password.js.

Agent and MCP Token Isolation

After JWT signature verification, the MCP, agent, and Eliza middleware functions (requireMCPAuth, requireAgentAuth, requireElizaAuth in src/middleware/auth.js) immediately re-query the live DynamoDB record for the entity. If the record is missing or status='suspended', the request is rejected with a 403 — the JWT alone does not grant access for the remainder of its TTL. This means a revocation via mcp/revoke.js or agent/suspend.js takes effect on the next request, not after token expiry.

Scopes are embedded in the token claims (scopes array) at issuance time from the allowedScopes field on the entity record. The middleware surfaces the decoded scopes so downstream handlers can enforce fine-grained permissions.

Agent tokens carry wallet (Ethereum address) and chainId claims so the downstream agent-auth middleware can validate against the on-chain identity without a separate lookup.

Token Extraction Priority

The getToken() function in src/middleware/auth.js checks, in order: (1) Authorization: Bearer <token> header, (2) __Host-githat_access cookie (production), (3) githat_access cookie (legacy/dev fallback). The getRefreshToken() function checks only the cookie path, reading __Host-githat_refresh then githat_refresh.

# API / server-to-server
Authorization: Bearer <token>

# Browser (cookie mode)
Cookie: __Host-githat_access=<access_token>; __Host-githat_refresh=<refresh_token>