Read-only user impersonation gives support read-only access to a customer’s account: they see what the customer sees, but can’t change anything or trigger actions on the customer’s behalf. The important word is enforce. Hiding edit buttons is helpful, but read-only only holds if your backend rejects prohibited operations when someone calls the API directly.
It’s a common request: an admin in an Auth0 Community thread (opens in a new tab) wants to “see what they see, but not be able to change any settings”. Security vendors make the same case. Authress’s guide to impersonation risks (opens in a new tab), for example, argues for giving support narrow, read-only permissions rather than full impersonation. Here’s how to define that boundary and test it.
Define what read-only means for your app
Write a short contract before you configure any routes. Otherwise two engineers can approve the same endpoint while assuming different things. Does “no changes” include last-viewed timestamps, notification read state, saved filters or analytics events?
A reasonable starting contract for a strict investigation session:
- Customer records, preferences and permissions stay unchanged.
- No messages are sent, jobs started or integrations triggered.
- No changes to passwords, MFA, recovery options, roles, API keys or payment details.
- Only the data needed for the ticket is visible.
- Access stops when the session expires or is revoked.
If you allow incidental writes, such as an audit log entry or a last-seen timestamp, list them by name. Keep repairs out of read-only sessions entirely. If support needs to fix something, use a separate, explicitly approved write session.
HTTP methods are not business behavior
A method filter is the usual first layer. In Devora’s scope rules (opens in a new tab), for example, a read-only session allows GET, HEAD and OPTIONS by default and treats every other method as a write. “Always blocked” rules apply in both read and write sessions. A specific read-like POST, such as a search endpoint, can get a narrow exception.
That filter catches most writes, but it can’t see what a handler actually does. A GET /invoices/recalculate-and-email changes data and sends mail. A POST /documents/search may only read. Review each handler and everything it calls.
Build an inventory with one row per operation:
- Method and concrete path.
- Authorization checks it performs.
- Tables it writes, queues it uses and external services it calls.
- How sensitive the response is.
- An owner.
Trace helpers and background jobs too. Mark anything you haven’t traced as “unreviewed”, not “probably read-only”.
If one endpoint handles both reads and writes depending on the payload, don’t give it a blanket exception. Split the route, or check the operation inside the handler, and test whether a different payload turns the safe request into a mutation.
Treat sensitive reads separately
An export changes nothing in the database but can disclose thousands of records. A document download can expose a private file. A settings endpoint can return secrets the UI never displays.
Block these in read-only sessions unless there is a specific support need and a review:
- Bulk exports and report downloads.
- Endpoints that return API keys, webhook secrets or tokens.
- Password recovery, MFA enrollment and role grants.
- Billing, payouts and payment methods.
- Destructive actions and audit-log deletion.
Look for aliases and alternate routes, not only the obvious URLs. Where support needs some of this information, prefer a narrow support view that returns a status or a redacted summary. Read-only scope doesn’t mask personal data. Response minimization is your application’s job.
Find every place the session is accepted
Enforcement has to happen wherever the support session’s credential is accepted.
Your API. Devora’s backend guard is the security boundary for routes you protect with it. It must be given impersonation context from verified authentication state, never from browser headers or request bodies. It rejects incomplete or expired context, and fails closed when it has no endpoint policy available. One detail matters here: if your code provides no impersonation context for a request, the guard treats it as ordinary customer traffic (opens in a new tab). Make sure every impersonated session reliably produces its context.
Direct-to-resource traffic. An API guard can’t see a database or storage SDK request that goes straight from the browser to that service. Those paths need their own resource-level rules, a guarded proxy, or exclusion from support sessions.
Tenant and permission checks. Keep them on every path. Read-only limits what support can do. It doesn’t replace the checks on what the customer may see.
For the wider set of responsibilities, see the multi-tenant impersonation checklist.
Test with an acceptance matrix, not screenshots
Run these against synthetic routes and non-production data with an ordinary customer role. Bypass the UI and call the API directly. The expected results are your pass conditions.
| Operation | Expected in a read-only session | Evidence |
|---|---|---|
GET /api/projects in the customer’s tenant | Allowed within the customer’s permissions | Response and tenant filter |
HEAD or OPTIONS on a read route | Allowed, no side effects | Status and state check |
POST, PATCH, DELETE on project data | Rejected | Backend denial and unchanged records |
GET /api/invoices/recalculate-and-email | Rejected by an explicit rule | No recalculation, job or email |
POST /api/documents/search | Rejected until reviewed, then a narrow exception | Review note and alternate-payload tests |
GET /api/customers/export | Rejected | No file, signed link or export job |
| Password, role, key, billing or audit-deletion endpoints | Rejected in read and write sessions | Denial for every entry point |
| Read from another tenant | Rejected by application authorization | No foreign records returned |
| Direct database or storage SDK call | Enforced by resource rules | Resource-policy result |
Ordinary GET after the session expires | Rejected, even from an open page | Backend response |
Ordinary GET after the session is ended | Rejected | Termination and request timestamps |
Record the effective policy, SDK versions, request IDs and downstream state. A denial isn’t enough if work was already queued before the request was rejected.
Rule changes and session endings
Caching affects how fast changes take effect, so test it. In Devora, endpoint policy is cached for five minutes (opens in a new tab). Editing a rule doesn’t invalidate caches already held by your backend. Session liveness is checked separately with a five-second default cache, and it doesn’t refresh the rules. If you need to cut access urgently, end the session and revoke your application’s credential. A rule edit alone isn’t enough.
When a session ends, Devora sends your backend a signed termination notice (opens in a new tab) even if no tab is open. Your application revokes the credential it issued. Browser callbacks only run while a tab is open. Test expiry, early termination, repeated termination and closed-tab termination separately.
Start with a narrow support view or a staging fixture if it can answer the ticket. If live inspection is necessary, review the provider prerequisites (opens in a new tab) and the testing guide (opens in a new tab). Then evaluate Devora in staging against your own operation inventory.
Sources
- Auth0 Community: How to impersonate user in read-only mode (2017) (opens in a new tab)
- Authress: User impersonation risks (opens in a new tab)
- Devora: Scope and security (opens in a new tab)
- Devora: Node SDK reference, scope guard (opens in a new tab)
- Devora: Session lifecycle (opens in a new tab)
- Devora: Authentication providers (opens in a new tab)
- Devora: Test the integration (opens in a new tab)