TOTP 2FA

Authenticator-app codes.

7 minmedium

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/setup
returns:
{ 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 yet
User 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.

apps/api/internal/totp/store.go (excerpt)
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.

Recovery codes — Grit also generates 8 one-time recovery codes when TOTP is enabled. Each can be used once to log in if the authenticator is lost. Show them once, never again, force the user to save them.

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

A SQL injection leaks the user_totps table. The attacker has every encrypted seed. What can they do?

Try it

Enable TOTP on your own account:

  1. Log in via the API. Grab the access token.
  2. POST /api/auth/totp/setup with the bearer token. Save the QR data URL.
  3. Open the QR URL in your browser (paste it into the address bar) — your browser renders the base64 image.
  4. Scan with 1Password / Authy / Google Authenticator.
  5. Type the 6-digit code to POST /api/auth/totp/enable with the bearer token.
  6. Log out, log back in — you should now see the totp_challenge response instead of tokens. Submit the new code to POST /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