User Impersonation ("Sign in as this user") | LlamaPress Cookbook
LlamaPress
Cookbook
Auth Stable

User Impersonation ("Sign in as this user")

Let an admin log in as another user to debug or support them, with a persistent "you are impersonating" banner and one-click return to the admin account.

Proven on llamapress.ai mothership controller · view
Live demo — try it Open full screen

User Impersonation ("Sign in as this user")

⚠️ Cookbook example — not live code. Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Impersonation lets a trusted admin become another user for a session — see exactly what that user sees, reproduce their bug, walk them through a screen — then drop back to their own account with one click. The whole trick is: stash the admin's real id in the session, sign_in the target user, and show a loud banner the whole time so nobody forgets who they are.

When to use: admin/support tooling where you need to debug a user's account from the inside. When not to: as a login shortcut for regular users, or without an audit trail if you handle sensitive data — see Gotchas.


The 80/20 in one breath

  1. Add two routes: POST /impersonate/:user_id (start) and POST /stop_impersonating (end).
  2. On start, save session[:impersonator_id] = current_user.id, then sign_in the target user.
  3. On stop, sign_in the user whose id is in session[:impersonator_id], then clear it.
  4. Add two helpers — true_user (the real admin behind the mask) and impersonating? — to ApplicationController.
  5. Render a fixed warning banner in the layout whenever impersonating? is true.

Devise's own sign_in swaps the session user for you. You are only bookkeeping who to go back to.


Layer 1 — Routes

# config/routes.rb
post "/impersonate/:user_id", to: "impersonations#create",  as: :impersonate
post "/stop_impersonating",   to: "impersonations#destroy", as: :stop_impersonating

Both are POST on purpose — impersonating is a state change, so it must not be a GET a crawler or a prefetch can trip.


Layer 2 — The controller

# app/controllers/impersonations_controller.rb
class ImpersonationsController < ApplicationController
  before_action :authenticate_user!
  before_action :ensure_admin!, only: [:create]

  def create
    user = User.find(params[:user_id])

    # Only let an admin impersonate someone in their own organization,
    # unless they're a superadmin. Scope this to YOUR authorization model.
    if current_user.organization_id == user.organization_id || current_user.admin?
      session[:impersonator_id] = current_user.id   # remember who we really are
      sign_in(:user, user)                          # Devise swaps the session user
      redirect_to root_path, notice: "Now impersonating #{user.email}"
    else
      redirect_to users_path, alert: "Not authorized to impersonate this user"
    end
  end

  def destroy
    if session[:impersonator_id]
      admin = User.find(session[:impersonator_id])
      session.delete(:impersonator_id)              # clear BEFORE sign_in, see Gotchas
      sign_in(:user, admin)                         # become the admin again
      redirect_to users_path, notice: "Stopped impersonating"
    else
      redirect_to root_path
    end
  end

  private

  def ensure_admin!
    redirect_to root_path, alert: "Not authorized" unless current_user.admin?
  end
end

destroy is deliberately not admin-gated — the current session user is the impersonated (non-admin) person, so gating it on admin? would trap them in the target account with no way out. The session[:impersonator_id] presence check is the only authorization destroy needs.


Layer 3 — Helpers on ApplicationController

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :true_user, :impersonating?

  # The real human behind the session. Falls back to current_user when nobody
  # is impersonating, so it's always safe to call in views.
  def true_user
    @true_user ||= User.find_by(id: session[:impersonator_id]) if session[:impersonator_id]
    @true_user ||= current_user
  end

  def impersonating?
    session[:impersonator_id].present?
  end
end

helper_method exposes both to views. Use current_user for "the account being viewed" and true_user for "the real admin" — the distinction matters for audit logging (log true_user.id did the action as current_user.id).


Layer 4 — The banner

Render this in your layout so it shows on every page while impersonating. A loud, fixed, high-contrast bar is the whole safety mechanism — it stops an admin from doing something in a customer's account thinking it's their own.

<%# app/views/layouts/application.html.erb  (just inside <body>, above <main>) %>
<% if impersonating? %>
  <div class="bg-warning text-warning-content px-4 py-2 flex justify-between items-center shadow-md">
    <div>
      <i class="fas fa-user-secret mr-2"></i>
      You are impersonating <strong><%= current_user.email %></strong>
      (Signed in as <strong><%= true_user.email %></strong>)
    </div>
    <%= button_to "Stop Impersonating", stop_impersonating_path, method: :post,
          class: "btn btn-sm btn-outline border-warning-content hover:bg-warning-content hover:text-warning" %>
  </div>
<% end %>

No Font Awesome on your box? Drop the <i> for an inline SVG or a plain "⚠" — the icon is decoration, the text and colour carry the meaning.


Layer 5 — The trigger button

Put this on your admin user list or a user's detail page. target: "_blank" opens the impersonated session in a new tab so the admin keeps their own tab logged in as themselves — a small quality-of-life win that avoids the round-trip through the banner.

<%# app/views/admin/users/show.html.erb  (or your user index row) %>
<%= button_to impersonate_path(@user), method: :post,
      class: "btn btn-primary btn-xs", form: { target: "_blank" } do %>
  <i class="fas fa-user-secret mr-1"></i> Sign in as this user
<% end %>

Gotchas (the hard-won stuff)

  • Clear the session key BEFORE sign_in in destroy, not after. Devise's sign_in resets the session to prevent session fixation, which can wipe keys depending on your setup and ordering. Delete impersonator_id first so you never end up half-reverted (signed back in as the admin but the app still thinks you're impersonating).
  • destroy must not be admin-gated. While impersonating, current_user is the target (usually a non-admin). An ensure_admin! on destroy locks them in with no exit. Only create gets the admin gate.
  • Scope who can impersonate whom. The example allows same-organization or superadmin. Never ship User.find(params[:user_id]) + sign_in without an authorization check — that's an account-takeover endpoint. Match the scope to your real permission model.
  • true_user vs current_user for audit logs. Any action taken while impersonating is done by current_user (the target) but caused by true_user (the admin). Log both, or you lose all accountability. If you store sensitive data, consider recording every impersonation start/stop to a table with admin id, target id, and timestamp.
  • POST-only routes. A GET /impersonate/:id would let a link, prefetch, or crawler silently switch accounts. Keep both routes POST and drive them with button_to, not link_to.
  • Banner lives in the layout, not a partial you forget to include. If it renders per-page, the one page missing it is where the accident happens. Put it once in the application layout, above yield.
  • Impersonation ignores the target's password/2FA. That's the point, and the risk — the admin account is now a skeleton key. Protect it with strong auth (see the linked 2FA guide) because compromising one admin compromises every user they can impersonate.

Files this pattern touches

config/routes.rb
app/controllers/impersonations_controller.rb
app/controllers/application_controller.rb   (true_user + impersonating? helpers)
app/views/layouts/application.html.erb       (the banner)
app/views/admin/users/show.html.erb          (the trigger button)

How to adapt to your schema

  1. Devise assumed. The pattern needs sign_in(:user, user) and current_user. On a different auth stack, replace both with your library's "set the current session user" and "read it back" calls — the session bookkeeping is identical.
  2. Rename the resource. If your model isn't User, swap the class and the :user scope in sign_in throughout.
  3. Rewrite the authorization check in create to your real rules (role column, Pundit policy, an admin?/support? flag). This is the one line you must not copy blindly.
  4. Drop the org scoping if you're single-tenant — keep only the admin? gate.
  5. Style the banner to whatever CSS you use; the only requirements are that it's impossible to miss and carries the Stop button.

Related