TOTP 2FA
Authenticator-app codes.
TOTP â Time-Based One-Time Password â is the 6-digit code your authenticator app generates (1Password, Authy, Google Authenticator). Grit ships TOTP 2FA with encrypted seed storage. Users opt in; admins can enforce it.
The setup flow
User clicks "Enable 2FA" in settingsâ POST /api/auth/totp/setupreturns:{ secret: "JBSWY3DPEHPK3PXP", qr_code: "data:image/png;base64..." }â User scans QR with their appâ User types the 6-digit code from the appâ POST /api/auth/totp/enable { code: "123456" }Grit verifies the code, marks 2FA enabled on the user
The login flow with TOTP enabled
User submits email + password to /api/auth/loginâ If 2FA enabled: response is { totp_challenge: <one-time-token> }NOT a full access token yetUser opens their authenticator app, types the 6-digit codeâ POST /api/auth/totp/verify { challenge: <token>, code: "123456" }â Grit verifies, returns the real access + refresh tokens
Encrypted seed storage
TOTP's security rests on the seed (the shared secret stored on the server). If a SQL injection or DB dump leaks it, the attacker can generate valid codes forever. Grit's defence: seeds are encrypted at rest with AES-GCM, keyed off JWT_SECRET.
func (s *Store) StoreSeed(userID, seed string) error {nonce := make([]byte, s.aead.NonceSize())rand.Read(nonce)encrypted := s.aead.Seal(nonce, nonce, []byte(seed), nil)return s.db.Save(&UserTOTP{UserID: userID,EncryptedSeed: encrypted,}).Error}
A SQL injection that leaks the table gets ciphertext â useless without the env-var key.
The trusted-device flow
Re-prompting for a TOTP code on every login is painful. Grit's flow: on successful TOTP verify, the user can tick "trust this device for 30 days". Grit issues a device-fingerprint cookie. Next login on the same device â fingerprint matches, TOTP is skipped.
What can break
- Clock drift â TOTP codes are time-windowed. If the server's clock is way off, codes will never verify. Grit allows Âą1 step (30 seconds) of drift by default.
- Wrong seed encoding â base32 vs base64 mismatches are a classic bug. Grit's setup endpoint returns base32 (what authenticator apps expect).
- Encrypted seed migration â if you rotate
JWT_SECRET, existing TOTP seeds can't be decrypted. Plan for re-enrolment if you rotate.
Quick check
Try it
Enable TOTP on your own account:
- Log in via the API. Grab the access token.
POST /api/auth/totp/setupwith the bearer token. Save the QR data URL.- Open the QR URL in your browser (paste it into the address bar) â your browser renders the base64 image.
- Scan with 1Password / Authy / Google Authenticator.
- Type the 6-digit code to
POST /api/auth/totp/enablewith the bearer token. - Log out, log back in â you should now see the
totp_challengeresponse instead of tokens. Submit the new code toPOST /api/auth/totp/verify.
Paste your totp_challenge response in notes.md as proof.
What's next
Last lesson of the chapter â RBAC + invitations. Roles, ownership, team invites. The pattern every multi-tenant app needs.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled â suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub