# How to Configure TOTP Two-Factor Authentication with Encrypted Secrets in Instatic

> Secure your Instatic account with TOTP two-factor authentication. Learn how Instatic encrypts secrets using AES-GCM, storing only ciphertext for enhanced security at rest.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: how-to-guide
- Published: 2026-07-30

---

**Instatic implements TOTP 2FA by generating a Base-32 secret, encrypting it with a 256-bit AES-GCM master key, and storing only the ciphertext, IV, and fingerprint, ensuring secrets remain encrypted at rest while allowing cryptographic verification during login.**

This guide walks through the complete **TOTP two-factor authentication** implementation in the CoreBunch/Instatic repository. The system uses a three-layer security architecture that keeps secrets encrypted in the database while still enabling standard time-based code verification compatible with authenticator apps like Google Authenticator or Authy.

## Understanding the Three-Layer Security Architecture

The Instatic codebase separates TOTP concerns into distinct layers to isolate cryptographic operations from business logic.

### Secret Generation and Provisioning

TOTP enrollment begins in [`server/auth/mfa.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/mfa.ts), where the `generateTotpSecret()` function creates a 20-byte random buffer and encodes it to Base-32. The `totpProvisioningUri()` helper constructs a standard `otpauth://` URL suitable for QR-code generation. During this phase, the raw secret is transmitted only to the client for immediate display and is never written to the database in plaintext.

### AES-GCM Encryption at Rest

When the user confirms their setup code, the system calls `encryptTotpSecret()` from [`server/auth/totpSecrets.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/totpSecrets.ts). This function loads the master key via `loadMasterKey()` and encrypts the secret using AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) via the `encryptSecret` utility in [`server/secrets/encryption.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/secrets/encryption.ts). The resulting ciphertext, initialization vector (IV), and a SHA-256 key fingerprint are stored in three distinct columns.

### Master Key Management

The encryption key itself is managed by [`server/secrets/masterKey.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/secrets/masterKey.ts). In production, you must provide a 256-bit key via the `INSTATIC_SECRET_KEY` environment variable (base64-encoded). Development environments auto-generate a key to `.tmp/secret.key`. The system computes a fingerprint from the first 16 hexadecimal characters of the key's SHA-256 hash to detect key rotation events.

## Step-by-Step TOTP Enrollment

Implementing TOTP requires coordinating between the client UI and the server-side encryption layer.

### Starting the Enrollment Process

Initiate enrollment by POSTing to `/admin/api/cms/me/mfa/totp/start`. The handler in [`server/handlers/cms/me.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/me.ts) returns both the raw secret and the provisioning URI:

```typescript
import { startCurrentUserTotpSetup } from './cmsAuth';

// Initiate TOTP setup
const { secret, otpauthUrl } = await startCurrentUserTotpSetup();

// Display QR code to user
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(otpauthUrl)}`;

```

The endpoint generates the secret using `generateTotpSecret()` but does not persist it. The client displays the QR code so the user can scan it into their authenticator application.

### Encrypting and Persisting the Secret

After the user enters the first 6-digit code from their authenticator app, confirm the setup by POSTing to `/admin/api/cms/me/mfa/totp/enable` with the secret and code:

```typescript
import { enableCurrentUserTotp } from './cmsAuth';

const userCode = '123456'; // Code from authenticator app

const { user, recoveryCodes } = await enableCurrentUserTotp({ 
  secret, 
  code: userCode 
});

console.log('MFA enabled for', user.email);
console.log('Recovery codes:', recoveryCodes);

```

On the server, `encryptTotpSecret()` encrypts the secret and stores three values defined in the database migrations ([`migrations-pg.ts`](https://github.com/CoreBunch/Instatic/blob/main/migrations-pg.ts) and [`migrations-sqlite.ts`](https://github.com/CoreBunch/Instatic/blob/main/migrations-sqlite.ts)):
- `mfa_totp_secret_ciphertext`: The AES-GCM encrypted secret
- `mfa_totp_secret_iv`: The nonce used for encryption
- `mfa_totp_secret_key_fingerprint`: The master key fingerprint for rotation detection

### Handling Master Key Rotation

If you rotate the `INSTATIC_SECRET_KEY`, existing encrypted secrets will fail decryption because the fingerprint check in `decryptTotpSecret()` detects the mismatch. The system throws a `TotpSecretError` with status 409, forcing affected users to re-enroll their MFA devices with the new key.

## Verifying TOTP Codes During Login

During authentication, the login handler in [`server/handlers/cms/auth.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/auth.ts) checks if the user has enabled MFA. If so, it calls `verifyUserTotpCode()`, which retrieves the encrypted columns from [`server/repositories/users.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/users.ts) and executes `verifyEncryptedTotpCode()`:

```typescript
import { loginCms, verifyCmsMfa } from './cmsAuth';

async function authenticate(email: string, password: string) {
  const { mfaRequired } = await loginCms({ email, password });
  
  if (mfaRequired) {
    const code = prompt('Enter your MFA code:');
    await verifyCmsMfa({ code });
  }
}

```

The verification path decrypts the secret using the current master key, then validates the TOTP code against the current time window using the same algorithm used during generation. If decryption fails due to a key mismatch, the server returns a `TotpSecretError` (status 500), requiring the administrator to reset the user's MFA.

## Administrative Secret Recovery

For maintenance scripts or debugging, you can manually reconstruct and verify encrypted secrets using the internal API:

```typescript
import { encryptedTotpSecretFromParts, verifyEncryptedTotpCode } from '../server/auth/totpSecrets';
import { getUserById } from '../server/repositories/users';

async function verifyUserCode(userId: string, code: string) {
  const row = await getUserById(userId);
  
  const encrypted = encryptedTotpSecretFromParts(
    row.mfa_totp_secret_ciphertext,
    row.mfa_totp_secret_iv,
    row.mfa_totp_secret_key_fingerprint,
  );
  
  const valid = await verifyEncryptedTotpCode(encrypted, code);
  return valid;
}

```

## Database Schema Requirements

The TOTP implementation requires three columns on the users table, added via non-destructive migrations:

**PostgreSQL** ([`server/db/migrations-pg.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-pg.ts)):

```typescript
// Lines 91-93
mfa_totp_secret_ciphertext: string | null
mfa_totp_secret_iv: string | null  
mfa_totp_secret_key_fingerprint: string | null

```

**SQLite** ([`server/db/migrations-sqlite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-sqlite.ts)):

```typescript
// Lines 87-89
mfa_totp_secret_ciphertext: string | null
mfa_totp_secret_iv: string | null
mfa_totp_secret_key_fingerprint: string | null

```

To disable TOTP for a user, send a DELETE request to `/admin/api/cms/me/mfa/totp`, which nullifies these columns.

## Summary

- **Enrollment flow**: Generate secrets with `totpProvisioningUri()`, encrypt with `encryptTotpSecret()`, and store ciphertext with IV and fingerprint.
- **Master key source**: Production requires `INSTATIC_SECRET_KEY` environment variable; development auto-generates to `.tmp/secret.key`.
- **Security model**: Secrets are encrypted using AES-GCM with a server-wide master key; only ciphertext touches the database.
- **Rotation safety**: The fingerprint column prevents silent decryption failures when keys rotate, forcing explicit re-enrollment.
- **Verification**: Login uses `verifyEncryptedTotpCode()` to decrypt on-the-fly and validate against the current time window.

## Frequently Asked Questions

### What happens if I rotate the INSTATIC_SECRET_KEY?

Existing TOTP secrets will become undecryptable. When a user attempts to log in or verify a code, the system compares the stored key fingerprint against the current master key's fingerprint. If they mismatch, the server returns a `TotpSecretError` (HTTP 409 or 500), and the user must re-enroll their authenticator app with a new secret generated under the current key.

### Can I decrypt TOTP secrets for backup purposes?

According to the Instatic source code, you can use `encryptedTotpSecretFromParts()` combined with `verifyEncryptedTotpCode()` from [`server/auth/totpSecrets.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/totpSecrets.ts) to validate codes without exposing the plaintext secret. However, the architecture intentionally discourages exporting raw secrets by requiring the master key for every verification.

### Why does the system store three separate columns instead of one encrypted blob?

Separating the ciphertext, IV (initialization vector), and key fingerprint allows the system to detect key rotation events without attempting full decryption. The fingerprint column stores the first 16 hex characters of the master key's SHA-256 hash, enabling early-fail detection in `decryptTotpSecret()` and clear error messaging for administrators.

### Is the TOTP secret ever stored in plaintext?

No. The raw Base-32 secret exists only in memory during the initial enrollment window and is transmitted to the client for QR-code display. Once the user confirms their first code, `encryptTotpSecret()` immediately encrypts the secret with AES-GCM before any database write occurs.