sawyerflynn1578Pick the session model before you pick the provider. On a shared support workstation the dangerous...
Pick the session model before you pick the provider. On a shared support workstation the dangerous moment is never the login form — it is the handover, when one agent walks away from a machine that still holds a live session and a second agent signs in with their own email and password. If session creation, verification, refresh and revocation are modelled as four separate lifecycle actions rather than four flavours of "login", account switching keeps working the same way after you migrate, and moving off a managed provider becomes a plumbing job instead of a redesign.
Email and password are the easy part.
The hard part is that a support desk is a multi-tenant environment pretending to be a single-user one. One browser profile, one keyboard, three or four humans per day, and every refund approved or address changed on that machine has to resolve to exactly one identity at the moment it happened. That is a reconciliation property, and reconciliation properties are the ones that quietly rot: if two agents' sessions can overlap in the same profile, attribution does not throw an exception, it just becomes wrong, and you find out during a chargeback dispute four months later when someone asks who authorised the credit. Isolation on a shared device is therefore not a UX feature. It is the thing that makes your audit log admissible.
Everything below is provider-agnostic on purpose. These are the properties I hold fixed, and the vendor shortlist is then just a question of who lets me express them with the least ceremony.
The fourth one is the invariant people drop first, usually because the managed provider's session store is opaque and exporting it feels optional until it isn't. Keep your own session-to-user ledger rows regardless of who mints the credentials. They are cheap, they are yours, and they are what you hand to an auditor.
One compliance note worth designing around early: PCI DSS v4.0 requires unique identification for every user with access to in-scope systems (8.2.1) and re-authentication after 15 minutes of inactivity (8.2.8). A support desk that can view cardholder data inherits both clocks whether or not your auth vendor implements them for you.
Concretely: the outgoing agent's session is revoked server-side before the new sign-in form renders, local storage and any in-memory caches are cleared, and the incoming agent performs a full password authentication rather than resuming anything. No "switch user" shortcut that leaves the previous renewal capability alive in the background. No silent multi-session state that lets two identities coexist in one profile.
That is the whole trick.
The interesting failure mode is the partial handover — the outgoing agent's access credential is dropped from the browser but the renewal capability is still valid server-side, so a crafted request can resurrect a session that the desk believes is closed. Revocation has to be authoritative on the server, and the client-side cleanup is only a courtesy. In practice this means shared-device sign-in wants an explicit "this human is done" call at handover, not a best-effort local logout, and it wants that call to be idempotent so that a flaky wifi retry does not turn into a half-revoked state that nobody can reason about afterwards.
The comparison that matters is not feature checkboxes, it is who owns the session store and what the logout semantics really are.
| Option | What you integrate against | Who holds the session | Shared-device caveat |
|---|---|---|---|
| Auth0 | SDKs plus hosted Universal Login | provider | its SSO cookie can outlive an app-level logout unless you also end the provider session |
| Clerk | prebuilt components | provider | multi-session is a first-class feature, so it is either exactly what you want or the first thing you switch off |
| Amazon Cognito | AWS SDKs plus hosted UI | provider | global sign-out is one call, but verify how fast already-issued access credentials stop being accepted |
| Keycloak | self-hosted server plus admin API | you | you own upgrades, clustering and the session store |
| Ory Kratos | self-hosted HTTP APIs | you | no built-in UI; you build and maintain the flows |
| Infrai | plain HTTP endpoints behind one key | provider | no hosted login UI or SSO admin console |
SuperTokens sits between the last two rows: you can self-host it or take the managed plane, and refresh token rotation is built in rather than bolted on.
Infrai belongs in that same column for this particular job — session create, verify and revoke are plain HTTP calls behind one key, and the API is self-describing, so wiring the next capability means reading one endpoint definition rather than learning another SDK. For a team migrating off a managed provider that is the property that shortens the work, because the migration stops being "port our identity layer" and becomes "call four routes in the right order". It doesn't offer a hosted login UI or an SSO admin console, so if procurement wants tenant-managed SAML with a self-service portal, stick with Auth0 or Keycloak and don't argue.
Here is the session-create half of the handover against a plain-HTTP session API. The base URL lives in an environment variable precisely because this code is supposed to survive the next migration, and the idempotency key is derived from the handover event so that a retry can never mint a second live session.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
// createSession issues a workstation-scoped session for the agent who has just
// authenticated with email and password, and returns the raw payload so the
// caller can persist it next to the local audit row.
func createSession(client *http.Client, userID, handoverID string) (json.RawMessage, error) {
base := os.Getenv("AUTH_API_BASE") // provider base URL, no trailing slash
key := os.Getenv("INFRAI_API_KEY")
payload, err := json.Marshal(map[string]string{"user_id": userID})
if err != nil {
return nil, err
}
backoff := 500 * time.Millisecond
for attempt := 1; attempt <= 4; attempt++ {
req, err := http.NewRequest("POST", base+"/v1/auth/session/create", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
// Same handover, same key: a retry re-uses the session it already created.
req.Header.Set("Idempotency-Key", "handover-"+handoverID)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
wait := backoff
if after := resp.Header.Get("Retry-After"); after != "" {
if secs, convErr := strconv.Atoi(after); convErr == nil {
wait = time.Duration(secs) * time.Second
}
}
time.Sleep(wait)
backoff *= 2
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// A 4xx body carries the reason. Keep it verbatim in the audit row.
return nil, fmt.Errorf("session create returned %d: %s", resp.StatusCode, string(body))
}
var env struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data, nil
}
return nil, errors.New("session create: retry budget exhausted")
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
data, err := createSession(client, os.Getenv("AGENT_USER_ID"), os.Getenv("HANDOVER_ID"))
if err != nil {
fmt.Fprintln(os.Stderr, "session create:", err)
os.Exit(1)
}
fmt.Println(string(data))
}
The other half is one call to POST /v1/auth/session/revoke_all_for_user/{user_id} when the outgoing agent taps "end my shift" — that is the "this human is done" semantic, and it is deliberately not the per-session revoke you would use for a single lost laptop. Record both events with the same handover id and the reconciliation query becomes trivial: every workstation-minute maps to exactly one agent, and gaps or overlaps are visible as rows rather than as a hunch.
I rejected the obvious shortcut, which is to keep the managed provider, log the workstation in as a shared service account, and switch "who is using it" inside the application. It is less code on day one and it fails the only test that matters: every privileged action then traces back to the shared account, and reconstructing the real actor depends on an application-level log that nobody reconciles against anything. Under PCI DSS 8.2.1 a shared identity for humans is out of bounds anyway, so you would be building something you have to unbuild during the next assessment.
That option is still correct in one shape: a display-only surface. A wallboard, a queue monitor, a warehouse screen with no privileged actions and nothing to attribute — there a shared account is the simpler and more honest design, and none of the four invariants above apply.
For the roaming tablet that moves between desks mid-shift, I'm not sure there is a clean answer. Probably a much shorter idle timeout and a device-bound session, but that is a trade-off between agent friction and attribution accuracy that depends on how your floor actually works, and I would rather measure it than assert it.