Skip to content

Add authentication

This recipe adds login to an app: a session that survives a reload, requests that carry the user's token, routes only signed-in users can reach, a return to the page the user was headed for, and a logout that doesn't leak one user's data into the next session. re-frame2 has no auth subsystem; each piece is an ordinary event, effect or route declaration.

It uses two add-on artefacts, routing (day8/re-frame2-routing) and managed HTTP (day8/re-frame2-http), and optionally resources for the logout step. The login form itself is Build a form.

1. The session slice

The session uses two app-db paths:

  • [:auth :user]: the signed-in user, or nil when nobody's logged in.
  • [:auth :token]: the credential that requests carry.

A non-secret top-level :auth-generation counter identifies the current session. Increment it on init, restore, successful login and logout; keep it outside :auth so replacing that slice cannot reset it. The HTTP hook below captures it to reject failures from a previous session.

The route guard checks :user, the request decorator reads :token, and logout clears both. Both have to survive a reload (the tip below explains why). Each frame has its own app-db, so a second frame on the page keeps its own session.

Persist the session through one effect

A page reload throws away app-db, so the session also lives in localStorage. Give persistence exactly one effect: it writes on a truthy token and removes on nil, so login, logout, and tests all go through the same code.

;; The recipe's namespaces require [re-frame.core :as rf], [re-frame.http.managed],
;; [re-frame.routing], and [re-frame.resources] (step 6 only). Requiring the
;; add-on namespaces registers their events and effects.
(rf/reg-fx :auth.session/persist
  {:doc       "Persist the session — a truthy :token and the identity it stands for
               — or clear it (nil :token)."
   :platforms #{:client}
   :sensitive [[:token]]}
  (fn [_frame-ctx {:keys [token user]}]
    (when-let [ls (.-localStorage js/globalThis)]
      (if token
        (.setItem    ls "auth-session"
                     (js/JSON.stringify (clj->js {:token token :user user})))
        (.removeItem ls "auth-session")))))

:platforms #{:client} matters under SSR, where there is no localStorage (a server session uses an http-only cookie). On a server the runtime skips the effect and records a :rf.fx/skipped-on-platform trace instead of crashing.

Store the identity as well as the token

The route guard in step 4 reads [:auth :user]. A boot that restores only the token has a valid credential and no signed-in user, so a reader who bookmarked a protected page is sent to login. Persisting the small user map beside the token puts the identity in app-db before the first URL is resolved. Cache display identity only (username, avatar), never a second copy of the token.

The server still decides. The token may have expired since the last visit; step 3's 401 response hook turns that into a clean logout, which is what makes the optimistic restore safe.

A localStorage token is readable by any script on your page

With an http-only cookie, the browser sends the credential and JavaScript cannot read it. Omit token storage and the bearer-header decorator, load the user from a session endpoint at boot, and call a server logout endpoint to clear the cookie. The route guard can still read [:auth :user]; while that boot request is pending, use the restoring-session branch described below.

Read the saved session back at boot

Without a boot read, every refresh logs the user out. Read storage in an effect and dispatch its result as an event. The event handler remains pure, and the credential never becomes a recordable coeffect. Classify the reply event's token separately from the app-db path it will populate.

(defn read-saved-session []
  (try
    (let [saved (some-> (.-localStorage js/globalThis)
                        (.getItem "auth-session")
                        js/JSON.parse
                        (js->clj :keywordize-keys true))]
      (when (and (map? saved) (string? (:token saved)) (map? (:user saved)))
        {:token (:token saved) :user (dissoc (:user saved) :token)}))
    (catch :default _ nil)))

(rf/reg-fx :auth.session/load
  {:platforms #{:client}}
  (fn [{:keys [frame]} _]
    (rf/dispatch [:auth/session-restored (read-saved-session)] {:frame frame})))

(rf/reg-event :auth/session-restored
  {:sensitive [[:token]]}
  (fn [{:keys [db]} [_ saved]]
    {:db (-> db
             (update :auth-generation (fnil inc 0))
             (update :auth assoc :user (:user saved) :token (:token saved)))}))

Unreadable storage, malformed JSON and an old session shape return nil, so boot starts logged out rather than aborting frame creation. The read is synchronous and its reply is queued into the same frame's current drain. Step 4 starts it from :initial-events, so the restored session is committed before the first URL is resolved. A network session lookup needs a separate restoring state, described below.

In a handler test, call :auth/session-restored with a fake session. In a pipeline test, override :auth.session/load to dispatch that fake reply; see Test a pipeline run.

Keep the secret out of traces

The token is a credential, so the init event in step 4 also returns a :sensitive classification effect for [:auth :token]. That classification covers app-db projections; the persistence effect and restore event above classify their own copies, and the login reply below classifies its payload. Handlers still receive the real value. Raw local epoch snapshots retain app-db for restoration, so project them before forwarding (Keep secrets and large things out of traces).

If your API hands out a token and nothing else

Some APIs expect you to exchange a stored bearer token for the current user with a GET /me at boot. The identity then arrives asynchronously, after the first URL has been resolved, so the guard in step 4 has to cope with a window where the token is known and the user is not. That costs a branch in the denial handler and a "restoring…" state in your shell. Both RealWorld examples take this shape (examples/real-apps/realworld_http/auth.cljs, under the cold-boot deep-link window), and Part 4 of the tutorial walks through it. Use it only when the API leaves you no choice. Once the reply has stored the user, dispatch [:rf.route/replan-resources {:cause [:session-restore]}] so the current route's resources re-plan under the resolved viewer ({:cause [:session-restore-failed]} after clearing a rejected token; examples/real-apps/realworld_resources/auth.cljs shows both). Deep links while a saved session is loading has the denial-handler branch.

2. Wire the login form

The login form is the one from Build a form, whose running example lives at [:auth :login]. The only change is that a successful submit establishes a session. Replace the form's :form.login/submit-success with this version, which stores the user and token, persists them, and sends the user on:

(rf/reg-event :form.login/submit-success
  {:sensitive [[:value :user :token]]}  ;; the reply's token, redacted in the event trace
  (fn [{:keys [db]} [_ {:keys [value]}]]
    (let [user (:user value)]            ;; server reply: {:user {... :token "..."}}
      {:db (-> db
               (update :auth-generation (fnil inc 0))
               (assoc-in [:auth :login :status]    :submitted)
               (assoc-in [:auth :login :submitted] (get-in db [:auth :login :draft]))
               (assoc-in [:auth :user]  (dissoc user :token))
               (assoc-in [:auth :token] (:token user)))
       :fx [[:auth.session/persist {:token (:token user)
                                    :user  (dissoc user :token)}]
            [:dispatch [:auth/post-login-redirect]]]})))

The token has one durable home, the classified [:auth :token] path. The user map is stored with :token stripped, so no unclassified copy sits at [:auth :user :token] to leak into off-box records. Both halves are persisted, so the next cold boot restores a session the route guard can see. The reply event itself carries the token too; the :sensitive [[:value :user :token]] metadata covers it, because paths in a registration's metadata are rooted at the event's arg-map, here the reply envelope.

The failure handler is unchanged from the form recipe. Don't add a :retry block to the login request: silently re-sending a credential submission can lock an account. Without one, managed HTTP delivers a 5xx or network drop as a failure reply and the user clicks again. A register form is the same wiring with a different URL and draft.

Gotcha: keep the password out of the trace on the way in

The form recipe edits the password through :form.login/edit-password, whose map payload is marked {:sensitive [[:value]]}. Keep it that way. A secret passed as a positional argument ([:auth/login "user" "secret"]) has no path to classify and appears unredacted in traces.

When to reach for a machine

Once login, register, and session restore start coordinating ("can't submit while restoring"), move the flow into a machine with states like idle → submitting/restoring → authed | error, as the RealWorld example's auth.cljs does (realworld_http). The sign is an if over a :status keyword growing into nested "but only if not also…" conditions.

3. Decorate requests once, at the frame boundary

Every authenticated request needs the token in an Authorization header. Threading it through each request builder means one forgotten call site ships an unauthenticated request.

Write it once, as an HTTP interceptor. These belong to managed HTTP and are separate from event interceptors. Its :before receives a context map (ctx) holding the in-flight request and returns it, edited. This one reads the token from the frame's app-db and adds the header to every managed request that frame sends:

;; cf. examples/real-apps/realworld_http/core.cljs
(defn- bearer-auth [ctx]
  (let [db    (rf/app-db-value (:frame ctx))
        token (get-in db [:auth :token])]
    (cond-> ctx
      token (assoc :auth/generation (:auth-generation db))
      token (assoc-in [:request :headers "Authorization"]
                      (str "Token " token)))))  ;; "Token" is RealWorld's scheme; yours may be "Bearer"

;; Register at boot, before the first authenticated request. Registration is
;; per frame; a bare top-level call raises :rf.error/no-frame-context.
(rf/with-frame :app
  (rf/reg-http-interceptor :my-app/bearer-auth
    {:before bearer-auth}))
  • It reads (:frame ctx), the frame this request runs under, so it keeps working on multi-frame pages (frame identity is carried, not found).
  • It returns ctx unchanged when there's no token, so login and public reads are untouched.
  • Authorization is on the framework's built-in header denylist, so the live request carries it while traces show it redacted.
  • It never fires for another frame's requests.

This is the same move as registering one axios request interceptor instead of passing a config object to every call.

The same chain has a response side, which is where you catch an expired token:

;; A 401 may expire only the session whose credential this request carried.
;; `:after` receives the reply envelope:
;;   {:status :ok :value …}
;;   {:status :error :error {:kind :rf.http/http-4xx :status 401 …}}
(rf/with-frame :app
  (rf/reg-http-interceptor :my-app/expired-session
    {:after (fn [ctx response]
              (when (and (contains? ctx :auth/generation)
                         (= :error (:status response))
                         (= :rf.http/http-4xx (get-in response [:error :kind]))
                         (= 401 (get-in response [:error :status])))
                ;; The reply runs in a transport callback with no frame in
                ;; scope, so a bare (rf/dispatch …) would raise
                ;; :rf.error/no-frame-context. Dispatch into this request's frame.
                (rf/dispatch [:auth/expired {:generation (:auth/generation ctx)}]
                             {:frame (:frame ctx)}))
              response)}))                       ;; :after must return the response

The post-:before context reaches :after unchanged, including :auth/generation. Anonymous requests carry no generation, so a rejected login does not trigger logout. The :auth/expired handler in step 6 compares the captured counter with current state when the event runs; checking only in this callback could race a queued login success.

Note the two :status levels. The reply's :status is :ok, :error, or :cancelled; the HTTP status code of a 4xx/5xx is at (get-in response [:error :status]), beside the failure :kind. Branch on the :kind keywords (Managed HTTP lists them), never on a message string.

To refresh the token and retry instead of logging out, drive the request from a state machine. Transport :retry decides from the failure category alone, so it can't wait on a second request (Build a form).

How the chain composes

HTTP interceptors run like event interceptors: :before in registration order, :after in reverse, and an interceptor with only one of the two is skipped on the other leg.

Gotcha: hot-reloading the interceptor

Re-evaluating reg-http-interceptor with the same id replaces it in place and keeps its position in the chain. (rf/clear :http-interceptor id) removes it, and a later re-registration appends it to the end of the chain. Don't clear-then-register in hot-reload code unless you want that.

4. Guard the protected routes

Some routes should open only for signed-in users. Declare a :can-enter guard on each protected route. The runtime checks it for every way a navigation can start (programmatic navigate, a route-link click, the URL bar, a reload, Back/Forward, the initial load, and SSR), so there is no per-entry-point code to write. For a rule that is not about routes, such as a maintenance-mode lockout, see A policy that is not about routes.

;; cf. examples/real-apps/realworld_http/routing.cljs
(rf/reg-route :app/home  {:doc "Home page."}    "/")
(rf/reg-route :app/login {:doc "Sign-in page."} "/login")

(rf/reg-route :app/settings
  {:doc       "Account settings."
   :tags      #{:requires-auth}
   :can-enter [:my-app/signed-in?]}
  "/settings")

(rf/reg-sub :auth/user
  (fn [db _] (get-in db [:auth :user])))

(rf/reg-sub :my-app/signed-in?
  {:doc "The :can-enter auth guard: true when a user is signed in."
   :inputs [[:auth/user]]}
  (fn [[user] _] (some? user)))               ;; true → OK to enter

The guard sub must return true or false; anything else refuses and raises :rf.error/can-enter-non-boolean, hence some?. It reads step 1's [:auth :user], which is why step 1 persists the identity as well as the token. :tags #{:requires-auth} is optional; the framework attaches no meaning to it.

A refused entry commits nothing and dispatches :rf.route/entry-denied, whose default handler does nothing (How the guard works shows the payload and the SSR 403). To send the visitor to login, replace that handler.

Bounce to login, remembering where they were headed

:destination carries the path params, query, and #fragment, and is itself a valid :rf.route/navigate request, so a deep link to /editor/my-post?draft=1#preview can come back to exactly that address. Stash it and redirect:

;; cf. examples/real-apps/realworld_http/routing.cljs
(rf/reg-event :rf.route/entry-denied
  {:doc "Send a logged-out visitor to login, remembering where they were headed."}
  (fn [{:keys [db]} [_ {:keys [destination]}]]
    {:db (assoc-in db [:auth :return-to] destination)
     :fx [[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]]}))
  • :replace? true keeps the refused URL off the back stack, so Back from /login doesn't run into the guard again.
  • Use :destination, not :requested-url. Re-parsing the URL string is how a query and a #fragment get lost.
  • No {:sensitive …} map is needed. The framework classifies the payload's URL fields as sensitive, and that carries over to your replacement handler, which still receives the real values. Require sign-in on a route has the details.

Wire the frame and restore the session

The guard needs no wiring: it is route metadata, and requiring the routing artefact makes the runtime check it. What remains is the frame that owns the URL, and the order of its boot.

Restore the session from the frame's :initial-events. A :url-bound? true frame runs every :initial-events step first and only then resolves the current URL, so the session is in app-db before any guard runs. A restore dispatched after make-frame returns is too late: the first URL has already been checked against an empty auth slice.

;; Seed and classify the slice before the load effect queues its reply.
(rf/reg-event :auth/init
  (fn [{:keys [db]} _]
    {:db        (-> db
                    (update :auth-generation (fnil inc 0))
                    (update :auth assoc :user nil :token nil))
     :sensitive [[:auth :token]]
     :fx        [[:auth.session/load]]}))

(rf/make-frame
  {:id             :app
   :doc            "The app frame."
   :url-bound?     true                            ;; this frame owns the browser URL
   :initial-events [[:auth/init]]})                ;; runs before the first URL is resolved

If you mount with frame-root as in Boot and mount an app, put the same :url-bound? true and :initial-events [[:auth/init]] on its options instead of calling make-frame.

Gotcha: restoring the session after the frame exists

;; Don't do this
(rf/make-frame {:id :app :url-bound? true})   ;; first URL resolved here
(rf/with-frame :app
  (rf/dispatch-sync [:auth/init]))            ;; session arrives too late

Tests that navigate somewhere first pass. Then a signed-in reader opens /settings directly, the guard runs against an empty auth slice, and they land on the login page holding a valid session. :initial-events steps run synchronously and in order, so if you need other state seeded before the auth read, make [:rf/set-db {…}] the first step.

Gotcha: exactly one frame owns the URL

:url-bound? true (url-bound?) makes this frame's navigation drive the browser address bar and Back/Forward. Only one frame may declare it; a second is still created, but the runtime reports :rf.error/duplicate-url-binding (an error record, not a throw) and the first keeps the URL. Leave any other frame on the page, such as Xray, a story, or a second app instance, URL-unbound so it routes in memory.

5. Bounce back after login

Step 2's success handler dispatches :auth/post-login-redirect. It navigates to the stashed destination with :replace? true (so /login stays off the back stack), and clears the stash in the same step:

;; cf. examples/real-apps/realworld_http/auth.cljs
(rf/reg-event :auth/post-login-redirect
  (fn [{:keys [db]} _]
    (let [return-to (get-in db [:auth :return-to])]
      {:db (update db :auth dissoc :return-to)
       ;; Navigate with the whole stash: a partial {:to :params} would drop
       ;; the query string and #fragment.
       :fx [[:dispatch (if return-to
                         [:rf.route/navigate (assoc return-to :replace? true)]
                         [:rf.route/navigate {:to :app/home}])]]})))

Clearing the stash matters. Otherwise a user who later logs in directly from /login is sent to a destination left over from an earlier refusal.

6. Logout is a teardown

Logout clears the session slice, the persisted session, and the departing user's cached server reads. Skip the last one and the next account sees the previous account's data.

If you use resources (managed, cached server reads), clearing one user's cache is one event, :rf.resource/clear-scope. It needs a name for "this user's scope", which is a named resource-scope resolver: a pure function, registered once, that derives the scope from app-db. Your resources, route loads, and logout all use the same resolver:

;; Pure: derives a scope from db; never fetches, dispatches, or reads ambient state.
(rf/reg-resource-scope :my-app/session
  {:inputs {:username [:db [:auth :user :username]]}}
  (fn [{:keys [username]} _ctx]
    (when username
      [:rf.scope/session {:username username}])))   ;; nil when logged out

In the logout handler, resolve the old scope from the handler's db before clearing the auth slice, because the scope derives from the identity you are about to remove:

(defn logout-effects [db]
  (let [old-scope (rf/resolve-resource-scope db :my-app/session)]
    {:db (-> db
             (update :auth-generation (fnil inc 0))
             (assoc-in [:auth :user] nil)
             (assoc-in [:auth :token] nil))
     ;; A nil :token removes the whole saved session, identity included.
     :fx (cond-> [[:auth.session/persist {:token nil}]]
           old-scope (conj [:dispatch [:rf.resource/clear-scope
                                      {:scope old-scope :cause :logout}]])
           true (conj [:dispatch [:rf.route/navigate {:to :app/home}]]))}))

(rf/reg-event :auth/logout
  (fn [{:keys [db]} _] (logout-effects db)))

(rf/reg-event :auth/expired
  (fn [{:keys [db]} [_ {:keys [generation]}]]
    (when (= generation (:auth-generation db))
      (logout-effects db))))

logout-effects applies the same teardown for an explicit logout and a current-session 401. A stale expiration returns no effects. The comparison and teardown belong in the same handler: dispatching an unguarded logout afterward would open another race.

clear-scope removes that scope's cache entries, releases their owners, aborts in-flight requests nothing else owns, ignores late replies for the cleared scope, and records a trace row listing what it removed, aborted, and left alone. Other scopes, such as public reads or a second signed-in frame, are untouched (Server state: resources). If you don't use resources, drop that :fx entry and skip the resolver.

Troubleshooting

Symptom Cause Fix
:rf.error/http-interceptor-bad-return on a logged-out request; neither reply event runs A :before returned nil, e.g. written as (when token …) Return ctx unchanged when there is nothing to add
:rf.error/http-bad-interceptor at registration The interceptor map has neither :before nor :after Supply at least one
Request never sent; :rf.error/http-interceptor-failed in the dev trace (error record :rf.error/fx-handler-exception) A :before threw Handle recoverable failures inside the interceptor
Neither reply event runs; :rf.error/http-reply-tail-failed An :after threw Handle recoverable failures inside the interceptor
:rf.error/resource-invalid-scope from logout clear-scope was given {:from-db …} Resolve the concrete scope in the handler before clearing the slice (step 6)

Check it in Xray

With all six steps wired, open Xray:

  • Logged out, click a link to a guarded route. The next row is the :rf.route/entry-denied dispatch, then your redirect to login; no :on-match or resource row appears for the protected route.
  • Logged in, open an authenticated request. The Authorization header shows as redacted, as does [:auth :token] in the app-db view.
  • Reload the page while signed in on a protected URL. The :auth/init row requests :auth.session/load, followed by the classified :auth/session-restored reply. Both events finish before the initial :rf.route/handle-url-change row, and the guarded route commits without an :rf.route/entry-denied. If the URL row ever comes first, the restore is no longer in :initial-events.
  • Dispatch :auth/logout. One clear-scope row lists what was removed, aborted, and left alone.
  • Start a request, then sign in to a new session before its 401 arrives. Its :auth/expired event carries the old generation and makes no change; a 401 from the current session runs the teardown.