go-auth
Guides

Admin

Admin login, platform-wide user management, per-user session control, and admin-issued platform invites.

Admin

Everything here requires role: "admin" on the calling user — routes are wrapped in RequireRole(domain.RoleAdmin, nil) on top of the normal AuthMiddleware, so an expired or missing session fails the same way it does everywhere else, and a valid session with role: "user" gets 403 forbidden.

For the full path/param reference see Routes. For viewing what admins (and everyone else) have done, see Audit Logs.

Configuration

There's no WithAdmin flag — these routes are always mounted. What gates them is purely the role column on domain.User, which is either "user" or "admin".

Bootstrapping the first admin

Every admin-management endpoint below requires an admin session to call — including POST /admin/users. That's a chicken-and-egg problem for the very first admin: there's no API route that creates one from nothing. Use the goauth seed-admin CLI command — see Creating the first admin below.

Creating the first admin

goauth seed-admin writes an admin user directly to the database, bypassing the API entirely — it's the supported answer to the chicken-and-egg problem above, so you don't have to hand-write an UPDATE users SET role = 'admin' WHERE email = '...' against production.

The standalone CLI does not load your application's Config, so it writes this bootstrap password as an unpeppered bcrypt row (password_pepper_version = NULL). In an application with versioned password peppering enabled, the first successful admin password check verifies that explicit legacy format and upgrades the row through the normal guarded rehash-on-login path before issuing the 2FA challenge or session. Until that first login, the bootstrap row has bcrypt protection but not the optional pepper layer.

go run github.com/nazimdjebloun/go-auth/cmd/goauth@latest seed-admin \
  --driver postgres --dsn "$DATABASE_URL" --env prod \
  --smtp-host smtp.example.com --smtp-from auth@example.com

Because admin login requires a second factor by default, a seeded admin who can't receive email may never be able to log in — so this command sends a real verification email to the address you're seeding, before writing anything to the database, and aborts with nothing written if that send fails. This check runs regardless of whether the deployment you're seeding for has set TwoFactorConfig.DisableAdminTwoFactor — the CLI has no way to know your running server's config, so it conservatively assumes the default (2FA on). --driver, --dsn, and --env are required; --env is dev, staging, or prod and controls which mailer default applies.

That email is fixed HTML-and-text content with no app name, links, or configurable parts — it lives in cmd/goauth/cmd/seed_admin_email.go and, unlike the library's emails, does not go through TemplateProvider. A compiled CLI has no way to accept a custom provider from an operator, so there is nothing to swap.

InputFlagEnv varNotes
Admin emailADMIN_EMAILPrompted for interactively if unset and stdin is a TTY; a hard error otherwise.
Admin passwordADMIN_PASSWORDValidated against the default password policy if set. Otherwise prompted (hidden input) with fallback to a strong auto-generated password, printed once and never shown again.
SMTP config--smtp-host, --smtp-port, --smtp-from, --smtp-user, --smtp-pass, --smtp-tlsSMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_USER, SMTP_PASS, SMTP_TLSRequired unless --env dev (see below) or --skip-mailer-check.
Force non-interactive--non-interactiveMissing ADMIN_EMAIL fails immediately instead of prompting — for CI.
Skip the mailer send--skip-mailer-checkStill builds a mailer if SMTP config is present; just doesn't require the send to succeed. Prints a loud warning either way.
Allow a second admin--forceWithout it, seeding refuses outright if any role: admin row already exists.

--env dev with no SMTP flags/env set uses a log-only mailer instead of real SMTP — the same LogMailer NewConfig defaults to automatically in EnvironmentDev. The "verification email" is written to the command's log output instead of actually sent, so local bootstrapping needs no SMTP setup at all:

go run github.com/nazimdjebloun/go-auth/cmd/goauth@latest seed-admin \
  --driver sqlite --dsn ./dev.db --env dev

If email/password aren't supplied and stdin is a TTY, both are prompted for; otherwise both auto-generate or error per the table above. The command refuses to write anything to the database if the mailer send fails or if an admin already exists without --force — see the flag table above.

Frontend client setup

The Client examples below call the same apiRequest(baseUrl, method, path, body) helper used throughout these guides — see Client → Setup. The named-method wrapper for everything on this page lives on Client → Admin.

Admin login

Admin login — POST /auth/admin/login

Same request shape as Login, but only succeeds for users whose role is "admin" — a correct password for a non-admin account fails exactly like a wrong password, and both count as a failed admin-login attempt in the audit log.

Request body

FieldTypeRequired
emailstringRequired
passwordstringRequired

Admin login requires a second factor by default

POST /auth/admin/login sends a 2FA challenge regardless of RequireEmail2FA and the account's TwoFactorEnabled setting. It therefore requires a mailer unless TwoFactorConfig.DisableAdminTwoFactor is set. Disabling it reduces protection for the highest-privilege login path. See Configuration and Security.

Response (200 OK) — never a session directly. A correct admin password returns a gated challenge, the same shape Login uses:

{
  "user": { "...": "role: admin" },
  "requiresTwoFactor": true,
  "codeSent": true,
  "challengeId": "c4a1...",
  "expiresAt": "2026-08-09T12:05:00Z",
  "message": "Two-factor code sent to your email"
}

No session/refresh cookie is set on this response — only the 2FA binding cookie. Complete the login with POST /auth/2fa/verify ({challengeId, code}) using the code emailed to the admin; that call is what actually sets goauth_session/goauth_refresh. See Two-factor authentication for the full request/response shape.

Errors

CodeStatusCause
invalid_json400Malformed body
invalid_credentials401Wrong password, unknown email, an OAuth-only account with no password, or correct credentials for a non-admin user — all indistinguishable on purpose
user_banned403Account is banned
email_not_configured500No mailer configured — see the callout above
internal_error500Session creation failure

curl

# 1. Password step — returns a challenge, not a session
curl -X POST https://api.myapp.com/auth/admin/login \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -c admin-cookies.txt \
  -d '{"email":"root@myapp.com","password":"a very strong admin password 1"}'

# 2. Second-factor step — this is what actually sets the session cookies
curl -X POST https://api.myapp.com/auth/2fa/verify \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt -c admin-cookies.txt \
  -d '{"challengeId":"c4a1...","code":"482913"}'

Use a separate cookie jar from your regular user session while testing — the verify response sets the same cookie names (goauth_session, goauth_refresh), so reusing a jar overwrites whichever session you had.


Programmatic (Go)

result, err := auth.Services.Auth.AdminLogin(ctx, goauth.LoginInput{
    Email:     "root@myapp.com",
    Password:  "a very strong admin password 1",
    IP:        r.RemoteAddr,
    UserAgent: r.UserAgent(),
})
if err != nil {
    // wraps a *domain.AuthError — invalid_credentials covers "not an admin" too
}
// result.RequiresTwoFactor is always true here — result.TwoFactorChallenge is
// the challenge id, result.BindingToken() is what the handler sets as a cookie.

verified, err := auth.Services.TwoFactor.Verify(
    ctx, result.TwoFactorChallenge, result.BindingToken(), code, r.RemoteAddr, r.UserAgent(),
)
if err != nil {
    // wraps a *domain.AuthError — see Error Handling → Handling errors programmatically
}
// verified.User is the admin; verified.SessionToken / verified.RefreshToken
// are the raw values — set them as cookies yourself

Client

const result = await apiRequest(API_BASE, "POST", "/auth/admin/login", { email, password });
// result.requiresTwoFactor is always true — route to a "enter your code" screen
setChallengeId(result.challengeId);

// after the admin enters the code:
const verified = await apiRequest(API_BASE, "POST", "/auth/2fa/verify", {
  challengeId,
  code,
});
setAdminUser(verified.user); // cookies are already set

Listing and inspecting users

List users — GET /admin/users

Query params

ParamTypeDefaultNotes
offsetint0
limitint20Max 100
emailstringExact match
searchstringMatches name or email
rolestringadmin or user only — any other value is silently ignored, not applied as a filter
isBannedbooltrue or false — anything else is ignored, not applied as a filter
isVerifiedbooltrue or false — anything else is ignored, not applied as a filter
twoFactorEnabledbooltrue or false — anything else is ignored, not applied as a filter
neverLoggedInbooltrue only — last_login_at IS NULL. Registered but never logged in since.
lastLoginBeforeRFC3339 timestamplast_login_at < X, excluding users who've never logged in (that's neverLoggedIn's job) — "logged in before, gone dormant since." Composable with neverLoggedIn for two different questions in one dashboard.
orderBystringcreated_atOr updated_at — anything else falls back to the default
orderDirectionstringdescOr asc — anything else falls back to the default

Response (200 OK)

{
  "users": [
    {
      "id": "b3f1...",
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "role": "user",
      "isVerified": true,
      "isBanned": false,
      "orgOwnerCount": 0,
      "createdAt": "2026-08-09T12:00:00Z",
      "updatedAt": "2026-08-09T12:00:00Z"
    }
  ],
  "limit": 20,
  "offset": 0
}

The list response carries no total — fetch it from Count users below.

curl

curl "https://api.myapp.com/admin/users?limit=20&search=ada&role=user" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

result, err := auth.Services.Admin.ListUsers(ctx, goauth.AdminListUsersInput{
    Limit:  20,
    Search: goauth.String("ada"),
})
// result.Users

Client

const { users } = await apiRequest(API_BASE, "GET", "/admin/users?limit=20&search=ada");

Count users — GET /admin/users/count

The matching total for List users, split into its own call: List no longer runs a COUNT(*) on every page, so a paginated table fetches the count once per filter change instead of once per page.

Query params: the filter params of List users (email, search, role, isBanned, isVerified, twoFactorEnabled, neverLoggedIn, lastLoginBefore). offset, limit, orderBy, orderDirection are accepted but ignored.

Response (200 OK): { "count": 150 }

curl

curl "https://api.myapp.com/admin/users/count?role=user&isBanned=true" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

n, err := auth.Services.Admin.CountUsers(ctx, goauth.AdminListUsersInput{
    Role: &roleUser,
})

Client

const { count } = await apiRequest(API_BASE, "GET", "/admin/users/count?role=user");

Get user detail — GET /admin/users/{id}

Bundles the user record with their active session count, whether they can sign in with a password, and linked OAuth provider accounts — one call instead of several.

Response (200 OK)

{
  "user": { "...": "same shape as list" },
  "activeSessionCount": 2,
  "hasPassword": true,
  "providers": [{ "provider": "github", "providerUserID": "12345" }]
}

hasPassword is false for an OAuth-only account that never set a password. The hash itself is never returned.

Errors

CodeStatusCause
user_not_found404No user with that ID

curl

curl https://api.myapp.com/admin/users/b3f1... \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

detail, err := auth.Services.Admin.GetUserDetail(ctx, goauth.GetUserDetailInput{
    UserID:  userID,
    ActorID: adminID, // the admin making this call
})
// detail.User, detail.ActiveSessionCount, detail.Providers

Client

const detail = await apiRequest(API_BASE, "GET", `/admin/users/${userId}`);

Creating and managing users

Create a user — POST /admin/users

Creates the user pre-verifiedisVerified: true from the moment it's created, no email round-trip. There's no session issued; this is admin provisioning, not a login.

Request body

FieldTypeRequired
emailstringRequired
passwordstringRequired — must pass SecurityConfig.PasswordPolicy
namestringRequired — rejected as name_required if blank after trimming
rolestringOptional — anything other than "admin" becomes "user"

Response (201 Created) — the created user object, same shape as list.

Errors

CodeStatusCause
invalid_json400Malformed body
name_required400Blank name
weak_password400Fails PasswordPolicy
email_already_exists409An account with that email already exists
internal_error500Hashing or database failure

curl

curl -X POST https://api.myapp.com/admin/users \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"email":"newadmin@myapp.com","password":"a very strong password 1","name":"New Admin","role":"admin"}'

Programmatic (Go)

user, err := auth.Services.Admin.CreateUser(ctx, goauth.CreateUserInput{
    Email:    "newadmin@myapp.com",
    Password: "a very strong password 1",
    Name:     "New Admin",
    Role:     "admin",
})

Client

const user = await apiRequest(API_BASE, "POST", "/admin/users", {
  email: "newadmin@myapp.com",
  password: "a very strong password 1",
  name: "New Admin",
  role: "admin",
});

Change a user's role — PATCH /admin/users/{id}/role

Request body: { "role": "admin" } — must be exactly "user" or "admin".

Response: { "message": "Role updated" }

Errors

CodeStatusCause
invalid_role400role wasn't "user" or "admin"
user_not_found404No user with that ID
last_admin400Would demote the only remaining admin

The last-admin check only fires on demotion

Promoting a user to admin is never blocked. The last_admin check only runs when the target is currently an admin and the new role is "user" — it exists purely to stop you from locking yourself (or everyone) out.

curl

curl -X PATCH https://api.myapp.com/admin/users/b3f1.../role \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"role":"admin"}'

Programmatic (Go)

err := auth.Services.Admin.UpdateUserRole(ctx, goauth.UpdateUserRoleInput{
    UserID:  userID,
    Role:    "admin",
    ActorID: adminID, // the admin making this call
})

Client

await apiRequest(API_BASE, "PATCH", `/admin/users/${userId}/role`, { role: "admin" });

Ban / unban a user — PATCH /admin/users/{id}/ban, PATCH /admin/users/{id}/unban

Banning revokes every one of that user's sessions immediately, in the same request — a banned user can't keep using an already-open tab. AuthService.ValidateSession also checks IsBanned on every request going forward, so even a session created after the ban (there isn't one, since sessions are revoked) or a stale in-memory session on the client would still be rejected server-side.

Response: { "message": "User banned successfully" } / { "message": "User unbanned successfully" }

Errors

CodeStatusCause
user_not_found404No user with that ID
already_banned400Ban called on an already-banned user
not_banned400Unban called on a user who isn't banned
last_admin400Ban would target the only remaining admin

curl

curl -X PATCH https://api.myapp.com/admin/users/b3f1.../ban \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Admin.BanUser(ctx, goauth.BanUserInput{UserID: userID, ActorID: adminID})
err  = auth.Services.Admin.UnbanUser(ctx, goauth.UnbanUserInput{UserID: userID, ActorID: adminID})

Client

await apiRequest(API_BASE, "PATCH", `/admin/users/${userId}/ban`);

Delete a user — DELETE /admin/users/{id}

Revokes all their sessions, then deletes the row. Irreversible.

Response: { "message": "User deleted successfully" }

Errors

CodeStatusCause
user_not_found404No user with that ID
last_admin400Would delete the only remaining admin

curl

curl -X DELETE https://api.myapp.com/admin/users/b3f1... \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Admin.DeleteUser(ctx, goauth.DeleteUserInput{UserID: userID, ActorID: adminID})

Client

await apiRequest(API_BASE, "DELETE", `/admin/users/${userId}`);

Managing a user's sessions

Same session shape and semantics as Sessions, just scoped to any user by ID instead of "yourself."

List a user's sessions — GET /admin/users/{id}/sessions

Query: offset, limit (default 20, max 100).

Response: { "sessions": [...], "total": 5 }

curl

curl "https://api.myapp.com/admin/users/b3f1.../sessions?limit=20" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

sessions, total, err := auth.Services.Admin.ListUserSessions(ctx, goauth.AdminListUserSessionsInput{
    ActorID: adminID, // the admin making this call
    UserID:  userID,
    Limit:   20,
})

Client

const { sessions, total } = await apiRequest(API_BASE, "GET", `/admin/users/${userId}/sessions`);

Revoke one / all of a user's sessions — DELETE /admin/users/{id}/sessions/{sessionId}, DELETE /admin/users/{id}/sessions

Errors (single-session route)

CodeStatusCause
user_not_found404No user with that ID
session_not_found404Session doesn't exist or doesn't belong to that user

curl

curl -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions/9e2c... \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

curl -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Admin.RevokeUserSession(ctx, goauth.RevokeUserSessionInput{
    UserID: userID, SessionID: sessionID, ActorID: adminID,
})
err  = auth.Services.Admin.RevokeUserSessions(ctx, goauth.RevokeUserSessionsInput{
    UserID: userID, ActorID: adminID,
}) // all of them

Client

await apiRequest(API_BASE, "DELETE", `/admin/users/${userId}/sessions/${sessionId}`);
await apiRequest(API_BASE, "DELETE", `/admin/users/${userId}/sessions`);

Sessions across every user — GET /admin/sessions

The incident-response view: active sessions platform-wide, not scoped to one user. Same base condition as ListUserSessions (not revoked, not yet expired), plus optional filters.

Query (all optional): offset, limit (default 20, max 100); userId, ip, search (substring-matches ip/user agent); createdAfter, createdBefore, expiresAfter, expiresBefore, lastActiveAfter, lastActiveBefore (RFC3339); orderBy (created_at default, expires_at, last_active_at); orderDirection (desc default, asc).

Response: { "sessions": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/sessions/count (below).

curl

curl "https://api.myapp.com/admin/sessions?ip=203.0.113.9" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

ip := "203.0.113.9"
result, err := auth.Services.Admin.ListSessions(ctx, goauth.AdminListSessionsInput{
    ActorID: adminID,
    IP:      &ip,
    Limit:   20,
})

Client

const { sessions } = await apiRequest(API_BASE, "GET", "/admin/sessions?ip=203.0.113.9");

Count sessions — GET /admin/sessions/count

Total for the filter above, on its own call (same rationale as Count users). Takes every filter param of GET /admin/sessions except offset/limit/orderBy/orderDirection.

Response: { "count": 5 }

n, err := auth.Services.Admin.CountSessions(ctx, goauth.AdminListSessionsInput{
    ActorID: adminID, IP: &ip,
})
const { count } = await apiRequest(API_BASE, "GET", "/admin/sessions/count?ip=203.0.113.9");

Bulk user actions — POST /admin/users/bulk/{ban,unban,delete,revoke-sessions}

Each endpoint applies the same single-user action (BanUser, UnbanUser, DeleteUser, RevokeUserSessions) to a list of user IDs. This is not atomic — go-auth has no batch primitive, so each ID is a separate sequential call under the hood. One user's failure (already banned, last admin, not found) doesn't stop or roll back the rest; read succeeded/failed rather than treating a 200 as "all succeeded."

Body: { "userIds": ["id1", "id2", ...] } — 1 to 100 IDs.

Response: { "succeeded": ["id1"], "failed": [{ "userId": "id2", "code": "last_admin", "message": "Cannot ban the last admin" }] }

code is the same stable AuthError code a single-user call would return — safe to match on.

curl

curl -X POST https://api.myapp.com/admin/users/bulk/ban \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF_TOKEN" \
  -b admin-cookies.txt \
  -d '{"userIds": ["b3f1...", "9e2c..."]}'

Programmatic (Go)

result, err := auth.Services.Admin.BulkBanUsers(ctx, goauth.BulkUserActionInput{
    UserIDs: []string{"b3f1...", "9e2c..."},
    ActorID: adminID,
})
// result.Succeeded, result.Failed

The same shape applies to BulkUnbanUsers, BulkDeleteUsers, and BulkRevokeUserSessions.


Client

const result = await apiRequest(API_BASE, "POST", "/admin/users/bulk/ban", {
  userIds: ["b3f1...", "9e2c..."],
});

Stats and activity

Three read-only endpoints for an admin dashboard — an overview snapshot, a registrations-over-time trend, and a GitHub-commit-style daily login-activity heatmap. All three read from data go-auth already tracks (users.last_login_at, users.created_at, and the audit log) — nothing new is stored just for this.

The login endpoints need WithAudit(Enabled: true)

GET /admin/stats/logins reads from the audit log — with auditing disabled it has nothing to query and returns an empty series, not an error. GET /admin/stats and GET /admin/stats/registrations don't depend on auditing at all; they read users directly.

Overview — GET /admin/stats

No params.

Response (200 OK)

{
  "totalUsers": 1204,
  "verifiedUsers": 1150,
  "bannedUsers": 3,
  "twoFactorEnabledUsers": 812,
  "neverLoggedInUsers": 47,
  "activeSessions": 389
}

Each field is its own COUNT(*) — six exact counts, no estimates. neverLoggedInUsers is last_login_at IS NULL — the same filter as List users → neverLoggedIn above, just counted rather than listed.

curl

curl https://api.myapp.com/admin/stats -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

stats, err := auth.Services.Admin.GetStats(ctx, adminID)

Client

const stats = await apiRequest(API_BASE, "GET", "/admin/stats");

Registration trend — GET /admin/stats/registrations

Query params: from, to — both required, RFC3339. Capped at a 400-day span (invalid_input beyond that).

Response (200 OK) — one bucket per day that had at least one registration; days with zero are simply absent, not zero-filled:

{
  "registrations": [
    { "date": "2026-08-01T00:00:00Z", "count": 4 },
    { "date": "2026-08-03T00:00:00Z", "count": 1 }
  ]
}

curl

curl "https://api.myapp.com/admin/stats/registrations?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

counts, err := auth.Services.Admin.GetRegistrationTrend(ctx, goauth.StatsRangeInput{
    ActorID: adminID, From: from, To: to,
})

Client

const { registrations } = await apiRequest(
  API_BASE, "GET", `/admin/stats/registrations?from=${from}&to=${to}`
);

Login activity — GET /admin/stats/logins

Query params: from, to — both required, RFC3339, same 400-day cap. userId — optional; omit for a global heatmap (every user's successful logins), set for one user's.

Counts login.success audit events only — email/password logins. OAuth (oauth.login) and admin (admin.login.success) logins are separate event types and aren't folded in.

Response (200 OK) — same shape as the registration trend, one bucket per day with at least one login:

{ "logins": [ { "date": "2026-08-01T00:00:00Z", "count": 12 } ] }

curl

# Global
curl "https://api.myapp.com/admin/stats/logins?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

# One user
curl "https://api.myapp.com/admin/stats/logins?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z&userId=b3f1..." \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

// Global
counts, err := auth.Services.Admin.GetLoginActivity(ctx, goauth.LoginActivityInput{
    ActorID: adminID, From: from, To: to,
})
// One user
counts, err = auth.Services.Admin.GetLoginActivity(ctx, goauth.LoginActivityInput{
    ActorID: adminID, UserID: &userID, From: from, To: to,
})

Client

const { logins } = await apiRequest(
  API_BASE, "GET", `/admin/stats/logins?from=${from}&to=${to}&userId=${userId}`
);

Organizations — requires WithOrganizations

Platform-admin oversight of organizations: list any org, view one regardless of the caller's own membership in it, and force a mutation an org's own owner/admin couldn't otherwise trigger from outside it. These call auth.Services.Org (not auth.Services.Admin) — the methods live on OrgService because it's only constructed when organizations are enabled, so they simply don't exist (and the routes below aren't mounted) when they're not, the same way every other /auth/orgs/* route already works.

Every mutation here publishes its own admin.org.* audit event

Distinct from the organization.* events the org's own owner/admin would publish for the same action — see Audit Logs for the full list and why the distinction matters. admin.org.deleted also snapshots the org's name/slug into the event's metadata, since the organizations row is hard-deleted and nothing else could resolve orgId back to a name afterward.

List organizations — GET /admin/orgs

Query (all optional): search (matches name or slug), createdAfter, createdBefore (RFC3339), orderBy (name default, created_at, member_count), orderDirection (asc default, desc), offset, limit (omit for default 20; 0 = unlimited; else capped at 100).

Response: { "orgs": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/orgs/count (below).

curl

curl "https://api.myapp.com/admin/orgs?search=acme" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

search := "acme"
result, err := auth.Services.Org.AdminListOrgs(ctx, goauth.AdminListOrgsInput{
    ActorID: adminID,
    Search:  &search,
})

Client

const { orgs } = await apiRequest(API_BASE, "GET", "/admin/orgs?search=acme");

Count organizations — GET /admin/orgs/count

Total for List organizations, split out for the same reason as Count users. Accepts search, createdAfter, createdBefore; ignores offset/limit/orderBy/orderDirection.

Response: { "count": 42 }

n, err := auth.Services.Org.CountOrgs(ctx, goauth.AdminListOrgsInput{
    ActorID: adminID, Search: &search,
})
const { count } = await apiRequest(API_BASE, "GET", "/admin/orgs/count?search=acme");

Get an organization — GET /admin/orgs/{orgID}

Returns the org regardless of the caller's own membership in it. Publishes admin.org.viewed.

curl

curl https://api.myapp.com/admin/orgs/org-123 \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

org, err := auth.Services.Org.AdminGetOrg(ctx, goauth.AdminGetOrgInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

const org = await apiRequest(API_BASE, "GET", "/admin/orgs/org-123");

List an organization's members — GET /admin/orgs/{orgID}/members

Same query params as the self-service GET /auth/orgs/{orgID}/members (see Organizations), but bypasses the membership check that endpoint applies — the caller doesn't need to be a member of orgID. Publishes admin.org.viewed. The response carries no total — use GET /admin/orgs/{orgID}/members/count below.

curl

curl https://api.myapp.com/admin/orgs/org-123/members \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

result, err := auth.Services.Org.AdminListOrgMembers(ctx, goauth.AdminListOrgMembersInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

const { members } = await apiRequest(API_BASE, "GET", "/admin/orgs/org-123/members");

Count an organization's members — GET /admin/orgs/{orgID}/members/count

Total for the list above. Accepts role and search; ignores pagination and ordering. Does not publish an audit event — it exposes only a number, not member data.

Response: { "count": 12 }

n, err := auth.Services.Org.AdminCountOrgMembers(ctx, goauth.AdminListOrgMembersInput{
    OrgID: "org-123", ActorID: adminID,
})
const { count } = await apiRequest(API_BASE, "GET", "/admin/orgs/org-123/members/count");

List a user's organizations — GET /admin/users/{id}/orgs

Every org {id} belongs to, filterable by the membership role. The self-service GET /auth/orgs only ever reads the caller's memberships; this reads someone else's, so it requires a platform admin. Responds 404 if {id} doesn't exist. Does not publish an audit event — it returns org metadata the admin can already list.

Query params: search (org name or slug), role (owner, admin or member — any other value is a 400, as on every org listing), orderBy (name default, created_at, member_count), orderDirection (asc default, desc), offset, limit (default 20, capped at 100; limit=0 means unlimited, as on the other list endpoints).

Response: { "orgs": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/users/{id}/orgs/count below.

The role filter is what makes this an offboarding tool

role=owner answers the question you actually have before deleting an account: which orgs would this leave ownerless? DELETE /admin/users/{id} won't tell you, and cannot_remove_last_owner only surfaces the problem one org at a time, after you've already started. Check this first, hand ownership over with PATCH /admin/orgs/{orgID}/members/{userID}/role, then delete.

curl

curl "https://api.myapp.com/admin/users/user-123/orgs?role=owner&orderBy=member_count&orderDirection=desc" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

role := domain.OrgRoleOwner
result, err := auth.Services.Org.AdminListUserOrgs(ctx, goauth.AdminListUserOrgsInput{
    ActorID: adminID, UserID: "user-123", Role: &role,
})
// result.Orgs, result.Limit, result.Offset

Role is a *domain.OrgRole — leave it nil for every role. Limit is a *int with the same nil / &0 semantics as the self-service listing (see Organizations).


Client

const { orgs } = await apiRequest(API_BASE, "GET", "/admin/users/user-123/orgs?role=owner");

Count a user's organizations — GET /admin/users/{id}/orgs/count

Total for the list above. Accepts search and role; ignores pagination and ordering.

Response: { "count": 3 }

n, err := auth.Services.Org.AdminCountUserOrgs(ctx, goauth.AdminListUserOrgsInput{
    ActorID: adminID, UserID: "user-123",
})
const { count } = await apiRequest(API_BASE, "GET", "/admin/users/user-123/orgs/count");

Add a member — POST /admin/orgs/{orgID}/members

Force-adds a user to an org regardless of the caller's own membership. This is the recovery path for an org whose only owner is gone (account deleted, offboarded, whatever the cause) and is otherwise unmanageable by anyone — AdminRemoveMember/AdminUpdateMemberRole alone can't fix that, since both require the target to already be a member. Publishes admin.org.member.added.

Request body: { "userId": "...", "role": "owner" | "admin" | "member" }

curl

curl -X POST https://api.myapp.com/admin/orgs/org-123/members \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF_TOKEN" \
  -b admin-cookies.txt \
  -d '{"userId": "b3f1...", "role": "owner"}'

Programmatic (Go)

err := auth.Services.Org.AdminAddMember(ctx, goauth.AdminAddMemberInput{
    OrgID: "org-123", UserID: "b3f1...", Role: domain.OrgRoleOwner, ActorID: adminID,
})

Client

await apiRequest(API_BASE, "POST", "/admin/orgs/org-123/members", {
  userId: "b3f1...",
  role: "owner",
});

Delete an organization — DELETE /admin/orgs/{orgID}

Force-deletes the org regardless of the caller's own membership. Publishes admin.org.deleted with the org's name/slug in the event metadata.

curl

curl -X DELETE https://api.myapp.com/admin/orgs/org-123 \
  -H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF_TOKEN" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Org.AdminDeleteOrg(ctx, goauth.AdminOrgActionInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

await apiRequest(API_BASE, "DELETE", "/admin/orgs/org-123");

Remove a member / change a member's role — DELETE /admin/orgs/{orgID}/members/{userID}, PATCH /admin/orgs/{orgID}/members/{userID}/role

Force-remove or force-change a role, regardless of the caller's own membership. Removal still refuses to remove an org's last owner — that's a repository-level invariant, not an authorization check, so it holds for an admin override too. The role change is the one place this deliberately behaves differently from the self-service version: granting or revoking Owner normally requires the acting user to already be an Owner of that org (so a same-org Admin can't self-promote); AdminUpdateMemberRole skips that guard, since a platform admin acting from outside the org isn't the same threat the guard exists for. Publishes admin.org.member.removed / admin.org.member.role_changed.

Role change request body: { "role": "owner" | "admin" | "member" }

curl

curl -X DELETE https://api.myapp.com/admin/orgs/org-123/members/b3f1... \
  -H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF_TOKEN" \
  -b admin-cookies.txt

curl -X PATCH https://api.myapp.com/admin/orgs/org-123/members/b3f1.../role \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF_TOKEN" \
  -b admin-cookies.txt \
  -d '{"role": "owner"}'

Programmatic (Go)

err := auth.Services.Org.AdminRemoveMember(ctx, goauth.AdminRemoveMemberInput{
    OrgID: "org-123", UserID: "b3f1...", ActorID: adminID,
})

err = auth.Services.Org.AdminUpdateMemberRole(ctx, goauth.AdminUpdateMemberRoleInput{
    OrgID: "org-123", UserID: "b3f1...", NewRole: domain.OrgRoleOwner, ActorID: adminID,
})

Client

await apiRequest(API_BASE, "DELETE", "/admin/orgs/org-123/members/b3f1...");
await apiRequest(API_BASE, "PATCH", "/admin/orgs/org-123/members/b3f1.../role", { role: "owner" });

Platform invites — requires EnableInvite

Admin-issued, invite-only signup: an admin creates an invite for an email address, and the recipient completes registration with it via POST /auth/invite/register — covered in Authentication → Register via invite. This is a different feature from organization invites — see Organizations → Invites for those. These routes only exist when RegistrationConfig.EnableInvite: true; otherwise they 404.

An invite's status is one of pending, accepted, revoked, or expired (the last one is set lazily — a pending invite past its expiresAt reports invite_expired the next time something reads it, rather than a background job flipping the status).

Create an invite — POST /admin/invites

The inviting admin comes from the session (AdminID), never from the request body.

Request body: { "email": "..." }

Response (201 Created)

{
  "id": "...",
  "email": "ada@example.com",
  "createdBy": "b3f1...",
  "status": "pending",
  "expiresAt": "2026-08-16T12:00:00Z",
  "createdAt": "2026-08-09T12:00:00Z"
}

The raw invite code is emailed directly to email — it's never included in this response (rawCode is only populated internally at creation time, before the mailer sends it).

Errors

CodeStatusCause
method_disabled405EnableInvite is false
invalid_json400Malformed body or invalid email
email_failed500No mailer configured, or send failure

curl

curl -X POST https://api.myapp.com/admin/invites \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"email":"ada@example.com"}'

Programmatic (Go)

invite, err := auth.Services.Invite.CreateInvite(ctx, goauth.CreateInviteInput{
    Email:   "ada@example.com",
    AdminID: adminUserID,
})

Client

const invite = await apiRequest(API_BASE, "POST", "/admin/invites", { email: "ada@example.com" });

List invites — GET /admin/invites

Query params: offset, limit (default 20, max 100); search, status (optional); orderBy (created_at default, expires_at, email, status), orderDirection (desc default, asc).

Response (200 OK): { "invites": [...] } — no total; use GET /admin/invites/count below.

curl

curl "https://api.myapp.com/admin/invites?status=pending&limit=20" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

invites, err := auth.Services.Invite.ListInvites(ctx, goauth.ListInvitesInput{
    Status: "pending",
    Limit:  20,
})

Client

const { invites } = await apiRequest(API_BASE, "GET", "/admin/invites?status=pending");

Count invites — GET /admin/invites/count

Total for the list, on its own call (same rationale as Count users). Accepts search and status; ignores pagination and ordering.

Response (200 OK): { "count": 42 }

n, err := auth.Services.Invite.CountInvites(ctx, goauth.ListInvitesInput{Status: "pending"})
const { count } = await apiRequest(API_BASE, "GET", "/admin/invites/count?status=pending");

Revoke an invite — DELETE /admin/invites/{id}

Soft-cancels it — sets status: "revoked". The row still exists, so it still shows up in List invites; an attempt to redeem it fails with invite_already_used instead of invite_not_found.

Response: { "message": "Invite revoked" }

curl

curl -X DELETE https://api.myapp.com/admin/invites/9e2c... \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Invite.RevokeInvite(ctx, inviteID)

Client

await apiRequest(API_BASE, "DELETE", `/admin/invites/${inviteId}`);

Resend an invite — POST /admin/invites/{id}/resend

Generates a new code and pushes expiresAt out by another InviteTTL — the previously emailed code stops working once this succeeds, so this is "issue a fresh one," not "re-send the same email."

Response: { "message": "Invite resent" }

Errors

CodeStatusCause
invite_not_found404No invite with that ID
email_failed500Send failure

curl

curl -X POST https://api.myapp.com/admin/invites/9e2c.../resend \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Invite.ResendInviteEmail(ctx, inviteID)

Client

await apiRequest(API_BASE, "POST", `/admin/invites/${inviteId}/resend`);

Permanently delete an invite — DELETE /admin/invites/{id}/hard

Unlike revoke, this removes the row entirely — no trace left in List invites. Use revoke for the normal "this invite is no longer valid" case; reach for this only when the record itself shouldn't exist anymore (e.g. it was created for the wrong email).

Response: { "message": "Invite deleted" }

curl

curl -X DELETE https://api.myapp.com/admin/invites/9e2c.../hard \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services.Invite.HardDeleteInvite(ctx, inviteID)

Client

await apiRequest(API_BASE, "DELETE", `/admin/invites/${inviteId}/hard`);

Next

  • Audit Logs — every admin action above (and everything else) shows up here
  • Sessions — the self-service version of session listing/revocation
  • Organizations → Invites — the other kind of invite, scoped to one org
  • Routes — full param reference

On this page

AdminConfigurationCreating the first adminFrontend client setupAdmin loginAdmin login — POST /auth/admin/logincurlProgrammatic (Go)ClientListing and inspecting usersList users — GET /admin/userscurlProgrammatic (Go)ClientCount users — GET /admin/users/countcurlProgrammatic (Go)ClientGet user detail — GET /admin/users/{id}curlProgrammatic (Go)ClientCreating and managing usersCreate a user — POST /admin/userscurlProgrammatic (Go)ClientChange a user's role — PATCH /admin/users/{id}/rolecurlProgrammatic (Go)ClientBan / unban a user — PATCH /admin/users/{id}/ban, PATCH /admin/users/{id}/unbancurlProgrammatic (Go)ClientDelete a user — DELETE /admin/users/{id}curlProgrammatic (Go)ClientManaging a user's sessionsList a user's sessions — GET /admin/users/{id}/sessionscurlProgrammatic (Go)ClientRevoke one / all of a user's sessions — DELETE /admin/users/{id}/sessions/{sessionId}, DELETE /admin/users/{id}/sessionscurlProgrammatic (Go)ClientSessions across every user — GET /admin/sessionscurlProgrammatic (Go)ClientCount sessions — GET /admin/sessions/countBulk user actions — POST /admin/users/bulk/{ban,unban,delete,revoke-sessions}curlProgrammatic (Go)ClientStats and activityOverview — GET /admin/statscurlProgrammatic (Go)ClientRegistration trend — GET /admin/stats/registrationscurlProgrammatic (Go)ClientLogin activity — GET /admin/stats/loginscurlProgrammatic (Go)ClientOrganizations — requires WithOrganizationsList organizations — GET /admin/orgscurlProgrammatic (Go)ClientCount organizations — GET /admin/orgs/countGet an organization — GET /admin/orgs/{orgID}curlProgrammatic (Go)ClientList an organization's members — GET /admin/orgs/{orgID}/memberscurlProgrammatic (Go)ClientCount an organization's members — GET /admin/orgs/{orgID}/members/countList a user's organizations — GET /admin/users/{id}/orgscurlProgrammatic (Go)ClientCount a user's organizations — GET /admin/users/{id}/orgs/countAdd a member — POST /admin/orgs/{orgID}/memberscurlProgrammatic (Go)ClientDelete an organization — DELETE /admin/orgs/{orgID}curlProgrammatic (Go)ClientRemove a member / change a member's role — DELETE /admin/orgs/{orgID}/members/{userID}, PATCH /admin/orgs/{orgID}/members/{userID}/rolecurlProgrammatic (Go)ClientPlatform invites — requires EnableInviteCreate an invite — POST /admin/invitescurlProgrammatic (Go)ClientList invites — GET /admin/invitescurlProgrammatic (Go)ClientCount invites — GET /admin/invites/countRevoke an invite — DELETE /admin/invites/{id}curlProgrammatic (Go)ClientResend an invite — POST /admin/invites/{id}/resendcurlProgrammatic (Go)ClientPermanently delete an invite — DELETE /admin/invites/{id}/hardcurlProgrammatic (Go)ClientNext