---
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.
---

# 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

```ruby
# 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

```ruby
# 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

```ruby
# 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.

```erb
<%# 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.

```erb
<%# 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.
