Skip to content

Use TOTP for magic codes#2398

Draft
dwwoelfel wants to merge 5 commits intomainfrom
totp-magic-code
Draft

Use TOTP for magic codes#2398
dwwoelfel wants to merge 5 commits intomainfrom
totp-magic-code

Conversation

@dwwoelfel
Copy link
Contributor

@dwwoelfel dwwoelfel commented Mar 21, 2026

Uses a TOTP for magic codes instead of generating a random code.

To generate a TOTP, you need a secret key. We want to have a separate key per app, per email. Each app gets its own secret key that we generate on demand and store encrypted in the database. Then we use hmac-sha256 with that secret key and the email for the code to generate the secret key for the TOTP code.

When you generate a totp, you have to pick an interval. We set the interval to 5 minutes and then we check across multiple past periods to get the desired interval. I think we'll want to make the default 3 periods, which will let every code expire between 10 and 15 minutes. But if you want it to last for 24 hours, then we'll just check your code against the last 288 periods.

While we roll out the change, we'll write the totp codes into the database like they were regular magic codes.

The full rollout will look like this:

  1. Deploy the code and wait for 24 hours
  2. Flip the validate-with-totp? toggle to true
  3. Wait for a while to make sure everything looks good
  4. Flip the dual-write-totp toggle to false
  5. Remove the code that writes to the magic codes table
  6. Later--remove the magic codes attrs from the database completely

We'll want to announce that we're reducing the expiry (that's in a feature flag and is currently set to 24 hours) and give users a chance to modify their totp expiration.

I made a couple of changes to make the testing easier:

  1. There are new *toggle-overrides* and *flag-overrides* dynamic vars so that you can override the config for individual tests
  2. The routes tests no longer hit the server, we just run the handler directly (without this change, the dynamic vars wouldn't be applied)

Still TODO:

  • UI for modifying your TOTP interval

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6344316d-ac90-46b7-a7f7-dcc176843825

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch totp-magic-code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes with custom instructions.

Set the reviews.auto_title_instructions setting to generate a title for your PR based on the changes in the PR with custom instructions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
server/src/instant/model/app.clj (1)

365-376: Race condition handling is sound, but ensure-totp-secret! return value is unused.

The three-step fallback pattern (read → ensure → read) correctly handles concurrent initialization. However, ensure-totp-secret! returns the result of sql/execute-one! (row count metadata), not the encrypted key, so Line 372's (:totp_secret_key_enc ...) will always be nil from that branch. The logic still works because the third read (Line 374) catches this case.

Consider simplifying by removing the unused middle extraction:

♻️ Optional refactor
 (defn get-totp-secret-key
   ([params]
    (get-totp-secret-key (aurora/conn-pool :read)
                         (aurora/conn-pool :write)
                         params))
   ([read-conn write-conn {:keys [id]}]
-   (let [secret-key-enc (or (:totp_secret_key_enc (get-by-id! read-conn {:id id}))
-                            (:totp_secret_key_enc (ensure-totp-secret! write-conn {:id id}))
-                            ;; 2nd read in case two txes tried to initialize the key at the same time
-                            (:totp_secret_key_enc (get-by-id! read-conn {:id id})))]
+   (let [secret-key-enc (or (:totp_secret_key_enc (get-by-id! read-conn {:id id}))
+                            (do (ensure-totp-secret! write-conn {:id id})
+                                ;; Re-read after ensure, handles both success and race condition
+                                (:totp_secret_key_enc (get-by-id! read-conn {:id id}))))]
      (crypt-util/aead-decrypt {:ciphertext secret-key-enc
                                :associated-data (uuid-util/->bytes id)}))))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/instant/model/app.clj` around lines 365 - 376, The middle branch
of the fallback is trying to extract :totp_secret_key_enc from the return of
ensure-totp-secret!, but ensure-totp-secret! returns row/metadata (not the row),
so that extraction is always nil; update the get-totp-secret-key function to
call ensure-totp-secret! for its side-effect (i.e., keep the read → ensure →
read pattern) and remove the unused (:totp_secret_key_enc (ensure-totp-secret!
...)) extraction — either replace that branch with a plain (ensure-totp-secret!
write-conn {:id id}) call or drop it and rely on the final get-by-id! call; keep
references to get-totp-secret-key, ensure-totp-secret!, and get-by-id! so the
logic and the second read remain intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/src/instant/flags.clj`:
- Around line 373-388: The validate-with-totp? toggle can enable validation
before generation, causing all verifications to fail; update validate-with-totp?
(or replace both flags with a single mode) so it only returns true when both the
validate toggle and generate toggle are enabled (i.e., require
generate-with-totp? to be true as a precondition), or refactor
generate-with-totp? and validate-with-totp? into a single rollout state (e.g.,
:totp-mode) and use that single source in validate-with-totp?,
generate-with-totp?, and dual-write-totp? to prevent the invalid
validate⇒generate ordering.

In `@server/src/instant/model/app_user_magic_code.clj`:
- Around line 47-60: The DB write that records issuance/consumption must not be
skipped when TOTP flags vary; currently the (when (or (not
(flags/validate-with-totp?)) ...)) around update-op causes create! to omit the
issuance record and consume! to allow replay. Remove or adjust that conditional
so update-op is always invoked for the issuance path in create! (and similarly
for the consume!/mark-consumed path), keeping the same update-op call that
writes {:id id :etype etype :codeHash ... :email email} so verify_magic_code and
consume! can check prior issuance/one-time use even when validate-with-totp? or
dual-write-totp? differ; ensure the same fix is applied to the other mirrored
block later in the file (the 84-103 equivalent).
- Around line 66-76: The expiry window is being unintentionally extended because
(inc expiry-periods) adds an extra period while instant.totp/valid-totp? already
includes the current period; change the call to totp/valid-totp? to pass
expiry-periods (not (inc expiry-periods)) and update the adjacent comment to
reflect that no additional increment is needed. Locate the expiry-periods
binding and the totp/valid-totp? invocation (the secret-key, expiry-periods,
code usage) and remove the inc usage so the configured expiry is honored.

In `@server/src/instant/runtime/routes.clj`:
- Around line 119-125: The test setup calls app-user-magic-code-model/create!
with the wrong keys: it passes :user-id and omits the required :email; update
the create! call to remove :user-id and include :email (e.g., :email
"stopa@instantdb.com") so it matches the function signature {:keys [app-id email
id]} and still binds the returned code to m for the subsequent
verify-magic-code-post calls; ensure the create! invocation remains
(app-user-magic-code-model/create! ...) and that verify-magic-code-post uses m
as the :code value.

---

Nitpick comments:
In `@server/src/instant/model/app.clj`:
- Around line 365-376: The middle branch of the fallback is trying to extract
:totp_secret_key_enc from the return of ensure-totp-secret!, but
ensure-totp-secret! returns row/metadata (not the row), so that extraction is
always nil; update the get-totp-secret-key function to call ensure-totp-secret!
for its side-effect (i.e., keep the read → ensure → read pattern) and remove the
unused (:totp_secret_key_enc (ensure-totp-secret! ...)) extraction — either
replace that branch with a plain (ensure-totp-secret! write-conn {:id id}) call
or drop it and rely on the final get-by-id! call; keep references to
get-totp-secret-key, ensure-totp-secret!, and get-by-id! so the logic and the
second read remain intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 11ef3dd5-c954-4acf-8e1b-0b1ced1994ad

📥 Commits

Reviewing files that changed from the base of the PR and between 0978a2d and ed63104.

📒 Files selected for processing (13)
  • server/resources/migrations/100_totp_magic_code.down.sql
  • server/resources/migrations/100_totp_magic_code.up.sql
  • server/src/instant/admin/routes.clj
  • server/src/instant/flags.clj
  • server/src/instant/model/app.clj
  • server/src/instant/model/app_user_magic_code.clj
  • server/src/instant/runtime/magic_code_auth.clj
  • server/src/instant/runtime/routes.clj
  • server/src/instant/totp.clj
  • server/src/instant/util/crypt.clj
  • server/test/instant/reactive/session_test.clj
  • server/test/instant/runtime/routes_test.clj
  • server/test/instant/totp_test.clj

Comment on lines +373 to +388
(defn generate-with-totp?
"Returns true if we should generate codes with totp. Need to run this for 24 hours before
turning on validate-with-totp"
[]
(toggled? :generate-with-totp true))

(defn validate-with-totp?
"Returns true if we should validate codes with totp. Need to generate-with-totp for 24 hours before
turning on validate-with-totp"
[]
(toggled? :validate-with-totp false))

(defn dual-write-totp?
"Returns true if we should write our totp codes to the magic codes table."
[]
(toggled? :dual-write-totp true))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These rollout flags still allow a broken auth state.

If validate-with-totp? is flipped on while generate-with-totp? is still off, create! sends a random DB-backed code but consume! still requires a TOTP match, so every verification fails. Please enforce validate ⇒ generate or collapse these booleans into a single rollout mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/instant/flags.clj` around lines 373 - 388, The validate-with-totp?
toggle can enable validation before generation, causing all verifications to
fail; update validate-with-totp? (or replace both flags with a single mode) so
it only returns true when both the validate toggle and generate toggle are
enabled (i.e., require generate-with-totp? to be true as a precondition), or
refactor generate-with-totp? and validate-with-totp? into a single rollout state
(e.g., :totp-mode) and use that single source in validate-with-totp?,
generate-with-totp?, and dual-write-totp? to prevent the invalid
validate⇒generate ordering.

Comment on lines +47 to +60
(when (or (not (flags/validate-with-totp?))
(not (flags/generate-with-totp?))
(flags/dual-write-totp?))
(update-op
conn
{:app-id app-id
:etype etype}
(fn [{:keys [transact!]}]
(transact! [{:id id
:etype etype
:codeHash (-> code
crypt-util/str->sha256
crypt-util/bytes->hex-string)
:email email}]))))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Cutting off the DB write removes both issuance tracking and one-time use.

When validate-with-totp? is true and dual-write-totp? is false, create! no longer records that a code was issued and consume! accepts any matching current TOTP for (app-id,email) without checking prior issuance or prior use. That means /verify_magic_code can succeed even if /send_magic_code was never called, and the same code can be replayed until it expires.

Also applies to: 84-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/instant/model/app_user_magic_code.clj` around lines 47 - 60, The
DB write that records issuance/consumption must not be skipped when TOTP flags
vary; currently the (when (or (not (flags/validate-with-totp?)) ...)) around
update-op causes create! to omit the issuance record and consume! to allow
replay. Remove or adjust that conditional so update-op is always invoked for the
issuance path in create! (and similarly for the consume!/mark-consumed path),
keeping the same update-op call that writes {:id id :etype etype :codeHash ...
:email email} so verify_magic_code and consume! can check prior
issuance/one-time use even when validate-with-totp? or dual-write-totp? differ;
ensure the same fix is applied to the other mirrored block later in the file
(the 84-103 equivalent).

Comment on lines +66 to +76
expiry-periods (or (some-> (app-model/get-by-id! {:id app-id})
:totp_expiry_minutes
(max 5) ;; Minimum of 5 minutes
(min 1440) ;; Maximum of 1 day
(* 60)
(/ totp/default-time-step)
(Math/ceil))
(/ (flags/totp-default-expiry-seconds) totp/default-time-step))]
;; Have to add 1 extra period in case the code was generated near the
;; end of a period
(when-not (totp/valid-totp? secret-key (inc expiry-periods) code)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

inc extends the validity window by one extra 5-minute period.

instant.totp/valid-totp? already counts the current period as one of the checked windows, so (inc expiry-periods) turns a 3-period policy into 4 and a 24-hour/288-period policy into 289. That keeps codes valid longer than the configured expiry.

Suggested fix
-    (when-not (totp/valid-totp? secret-key (inc expiry-periods) code)
+    (when-not (totp/valid-totp? secret-key expiry-periods code)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expiry-periods (or (some-> (app-model/get-by-id! {:id app-id})
:totp_expiry_minutes
(max 5) ;; Minimum of 5 minutes
(min 1440) ;; Maximum of 1 day
(* 60)
(/ totp/default-time-step)
(Math/ceil))
(/ (flags/totp-default-expiry-seconds) totp/default-time-step))]
;; Have to add 1 extra period in case the code was generated near the
;; end of a period
(when-not (totp/valid-totp? secret-key (inc expiry-periods) code)
expiry-periods (or (some-> (app-model/get-by-id! {:id app-id})
:totp_expiry_minutes
(max 5) ;; Minimum of 5 minutes
(min 1440) ;; Maximum of 1 day
(* 60)
(/ totp/default-time-step)
(Math/ceil))
(/ (flags/totp-default-expiry-seconds) totp/default-time-step))]
;; Have to add 1 extra period in case the code was generated near the
;; end of a period
(when-not (totp/valid-totp? secret-key expiry-periods code)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/instant/model/app_user_magic_code.clj` around lines 66 - 76, The
expiry window is being unintentionally extended because (inc expiry-periods)
adds an extra period while instant.totp/valid-totp? already includes the current
period; change the call to totp/valid-totp? to pass expiry-periods (not (inc
expiry-periods)) and update the adjacent comment to reflect that no additional
increment is needed. Locate the expiry-periods binding and the totp/valid-totp?
invocation (the secret-key, expiry-periods, code usage) and remove the inc usage
so the configured expiry is honored.

Comment on lines 119 to +125
(def m (app-user-magic-code-model/create!
{:id (random-uuid) :user-id (:id runtime-user) :code (app-user-magic-code-model/rand-code)
{:id (random-uuid) :user-id (:id runtime-user)
:app-id (:id app)}))
(verify-magic-code-post {:body {:email "stopainstantdb.com" :code (:code m)}})
(verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m)}})
(verify-magic-code-post {:body {:email "stopainstantdb.com" :code m}})
(verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m}})
(verify-magic-code-post {:body {:email "stopa@instantdb.com" :code "0" :app-id (:id app)}})
(verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m) :app-id (:id app)}}))
(verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m :app-id (:id app)}}))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the current signature of app-user-magic-code-model/create!
rg -n "defn create!" server/src/instant/model/app_user_magic_code.clj -A 10

Repository: instantdb/instant

Length of output: 458


🏁 Script executed:

#!/bin/bash
# Get the complete create! function implementation
rg -n "defn create!" server/src/instant/model/app_user_magic_code.clj -A 30

Repository: instantdb/instant

Length of output: 1276


🏁 Script executed:

#!/bin/bash
# Search for other usages of create! in the test files to see correct parameter usage
rg "app-user-magic-code-model/create!" server/src/instant/runtime/routes.clj -B 2 -A 5

Repository: instantdb/instant

Length of output: 517


🏁 Script executed:

#!/bin/bash
# Look for test files that might show how create! should be used
fd "test.*\.clj$" | xargs rg "app-user-magic-code-model/create!" -l 2>/dev/null | head -5

Repository: instantdb/instant

Length of output: 43


The comment block test code has incorrect parameters for create!.

The create! function signature expects {:keys [app-id email id]}, but the call on line 120 includes :user-id (which does not exist) and omits :email (which is required). Remove :user-id and add :email to the call. The use of m as :code in the subsequent verify calls is correct since create! returns the generated code string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/instant/runtime/routes.clj` around lines 119 - 125, The test setup
calls app-user-magic-code-model/create! with the wrong keys: it passes :user-id
and omits the required :email; update the create! call to remove :user-id and
include :email (e.g., :email "stopa@instantdb.com") so it matches the function
signature {:keys [app-id email id]} and still binds the returned code to m for
the subsequent verify-magic-code-post calls; ensure the create! invocation
remains (app-user-magic-code-model/create! ...) and that verify-magic-code-post
uses m as the :code value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant