{"slug":"user-impersonation","meta":{"title":"User Impersonation (\"Sign in as this user\")","slug":"user-impersonation","category":"Auth","summary":"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.","tags":["devise","auth","admin","support","session"],"status":"stable","visibility":"public","source_project":"llamapress.ai mothership","layers":["controller","view"],"related":[{"title":"One-Click Demo Sign-In","url":"/cookbook/one-click-demo-sign-in","summary":"Sign a visitor straight into a seeded demo account — the same sign_in move, no admin gate."},{"title":"Two-Factor Authentication (TOTP)","url":"/cookbook/two-factor-authentication-totp","summary":"Harden the admin account that holds the impersonation power."}]},"body":"# User Impersonation (\"Sign in as this user\")\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it there.\n\nImpersonation lets a trusted admin become another user for a session — see exactly what\nthat user sees, reproduce their bug, walk them through a screen — then drop back to their\nown account with one click. The whole trick is: stash the admin's real id in the session,\n`sign_in` the target user, and show a loud banner the whole time so nobody forgets who\nthey are.\n\n\u003e **When to use:** admin/support tooling where you need to debug a user's account from\n\u003e the inside. **When not to:** as a login shortcut for regular users, or without an\n\u003e audit trail if you handle sensitive data — see Gotchas.\n\n---\n\n## The 80/20 in one breath\n\n1. Add two routes: `POST /impersonate/:user_id` (start) and `POST /stop_impersonating` (end).\n2. On **start**, save `session[:impersonator_id] = current_user.id`, then `sign_in` the target user.\n3. On **stop**, `sign_in` the user whose id is in `session[:impersonator_id]`, then clear it.\n4. Add two helpers — `true_user` (the real admin behind the mask) and `impersonating?` — to `ApplicationController`.\n5. Render a fixed warning banner in the layout whenever `impersonating?` is true.\n\nDevise's own `sign_in` swaps the session user for you. You are only bookkeeping who to\ngo back to.\n\n---\n\n## Layer 1 — Routes\n\n```ruby\n# config/routes.rb\npost \"/impersonate/:user_id\", to: \"impersonations#create\",  as: :impersonate\npost \"/stop_impersonating\",   to: \"impersonations#destroy\", as: :stop_impersonating\n```\n\nBoth are `POST` on purpose — impersonating is a state change, so it must not be a `GET`\na crawler or a prefetch can trip.\n\n---\n\n## Layer 2 — The controller\n\n```ruby\n# app/controllers/impersonations_controller.rb\nclass ImpersonationsController \u003c ApplicationController\n  before_action :authenticate_user!\n  before_action :ensure_admin!, only: [:create]\n\n  def create\n    user = User.find(params[:user_id])\n\n    # Only let an admin impersonate someone in their own organization,\n    # unless they're a superadmin. Scope this to YOUR authorization model.\n    if current_user.organization_id == user.organization_id || current_user.admin?\n      session[:impersonator_id] = current_user.id   # remember who we really are\n      sign_in(:user, user)                          # Devise swaps the session user\n      redirect_to root_path, notice: \"Now impersonating #{user.email}\"\n    else\n      redirect_to users_path, alert: \"Not authorized to impersonate this user\"\n    end\n  end\n\n  def destroy\n    if session[:impersonator_id]\n      admin = User.find(session[:impersonator_id])\n      session.delete(:impersonator_id)              # clear BEFORE sign_in, see Gotchas\n      sign_in(:user, admin)                         # become the admin again\n      redirect_to users_path, notice: \"Stopped impersonating\"\n    else\n      redirect_to root_path\n    end\n  end\n\n  private\n\n  def ensure_admin!\n    redirect_to root_path, alert: \"Not authorized\" unless current_user.admin?\n  end\nend\n```\n\n`destroy` is deliberately **not** admin-gated — the current session user is the\nimpersonated (non-admin) person, so gating it on `admin?` would trap them in the target\naccount with no way out. The `session[:impersonator_id]` presence check is the only\nauthorization `destroy` needs.\n\n---\n\n## Layer 3 — Helpers on ApplicationController\n\n```ruby\n# app/controllers/application_controller.rb\nclass ApplicationController \u003c ActionController::Base\n  helper_method :true_user, :impersonating?\n\n  # The real human behind the session. Falls back to current_user when nobody\n  # is impersonating, so it's always safe to call in views.\n  def true_user\n    @true_user ||= User.find_by(id: session[:impersonator_id]) if session[:impersonator_id]\n    @true_user ||= current_user\n  end\n\n  def impersonating?\n    session[:impersonator_id].present?\n  end\nend\n```\n\n`helper_method` exposes both to views. Use `current_user` for \"the account being viewed\"\nand `true_user` for \"the real admin\" — the distinction matters for audit logging (log\n`true_user.id` did the action **as** `current_user.id`).\n\n---\n\n## Layer 4 — The banner\n\nRender this in your layout so it shows on every page while impersonating. A loud, fixed,\nhigh-contrast bar is the whole safety mechanism — it stops an admin from doing something\nin a customer's account thinking it's their own.\n\n```erb\n\u003c%# app/views/layouts/application.html.erb  (just inside \u003cbody\u003e, above \u003cmain\u003e) %\u003e\n\u003c% if impersonating? %\u003e\n  \u003cdiv class=\"bg-warning text-warning-content px-4 py-2 flex justify-between items-center shadow-md\"\u003e\n    \u003cdiv\u003e\n      \u003ci class=\"fas fa-user-secret mr-2\"\u003e\u003c/i\u003e\n      You are impersonating \u003cstrong\u003e\u003c%= current_user.email %\u003e\u003c/strong\u003e\n      (Signed in as \u003cstrong\u003e\u003c%= true_user.email %\u003e\u003c/strong\u003e)\n    \u003c/div\u003e\n    \u003c%= button_to \"Stop Impersonating\", stop_impersonating_path, method: :post,\n          class: \"btn btn-sm btn-outline border-warning-content hover:bg-warning-content hover:text-warning\" %\u003e\n  \u003c/div\u003e\n\u003c% end %\u003e\n```\n\nNo Font Awesome on your box? Drop the `\u003ci\u003e` for an inline SVG or a plain \"⚠\" — the icon\nis decoration, the text and colour carry the meaning.\n\n---\n\n## Layer 5 — The trigger button\n\nPut this on your admin user list or a user's detail page. `target: \"_blank\"` opens the\nimpersonated session in a **new tab** so the admin keeps their own tab logged in as\nthemselves — a small quality-of-life win that avoids the round-trip through the banner.\n\n```erb\n\u003c%# app/views/admin/users/show.html.erb  (or your user index row) %\u003e\n\u003c%= button_to impersonate_path(@user), method: :post,\n      class: \"btn btn-primary btn-xs\", form: { target: \"_blank\" } do %\u003e\n  \u003ci class=\"fas fa-user-secret mr-1\"\u003e\u003c/i\u003e Sign in as this user\n\u003c% end %\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Clear the session key BEFORE `sign_in` in `destroy`, not after.** Devise's `sign_in`\n  resets the session to prevent session fixation, which can wipe keys depending on your\n  setup and ordering. Delete `impersonator_id` first so you never end up half-reverted\n  (signed back in as the admin but the app still thinks you're impersonating).\n- **`destroy` must not be admin-gated.** While impersonating, `current_user` is the\n  target (usually a non-admin). An `ensure_admin!` on `destroy` locks them in with no\n  exit. Only `create` gets the admin gate.\n- **Scope who can impersonate whom.** The example allows same-organization or superadmin.\n  Never ship `User.find(params[:user_id])` + `sign_in` without an authorization check —\n  that's an account-takeover endpoint. Match the scope to your real permission model.\n- **`true_user` vs `current_user` for audit logs.** Any action taken while impersonating\n  is done by `current_user` (the target) but *caused by* `true_user` (the admin). Log\n  both, or you lose all accountability. If you store sensitive data, consider recording\n  every impersonation start/stop to a table with admin id, target id, and timestamp.\n- **POST-only routes.** A `GET /impersonate/:id` would let a link, prefetch, or crawler\n  silently switch accounts. Keep both routes `POST` and drive them with `button_to`, not\n  `link_to`.\n- **Banner lives in the layout, not a partial you forget to include.** If it renders\n  per-page, the one page missing it is where the accident happens. Put it once in the\n  application layout, above `yield`.\n- **Impersonation ignores the target's password/2FA.** That's the point, and the risk —\n  the admin account is now a skeleton key. Protect it with strong auth (see the linked\n  2FA guide) because compromising one admin compromises every user they can impersonate.\n\n---\n\n## Files this pattern touches\n\n```\nconfig/routes.rb\napp/controllers/impersonations_controller.rb\napp/controllers/application_controller.rb   (true_user + impersonating? helpers)\napp/views/layouts/application.html.erb       (the banner)\napp/views/admin/users/show.html.erb          (the trigger button)\n```\n\n## How to adapt to your schema\n\n1. **Devise assumed.** The pattern needs `sign_in(:user, user)` and `current_user`. On a\n   different auth stack, replace both with your library's \"set the current session user\"\n   and \"read it back\" calls — the session bookkeeping is identical.\n2. **Rename the resource.** If your model isn't `User`, swap the class and the `:user`\n   scope in `sign_in` throughout.\n3. **Rewrite the authorization check** in `create` to your real rules (role column,\n   Pundit policy, an `admin?`/`support?` flag). This is the one line you must not copy\n   blindly.\n4. **Drop the org scoping** if you're single-tenant — keep only the `admin?` gate.\n5. **Style the banner** to whatever CSS you use; the only requirements are that it's\n   impossible to miss and carries the Stop button.\n"}