Admin
Admin login, platform-wide user management, per-user session control, and admin-issued platform invites.
Admin
This guide's fixed admin/user role examples apply when app permissions are disabled.
With WithAppPermissions enabled, app administrative operations can be
delegated individually to custom roles through installed library keys;
organization oversight and catalog provisioning remain protected-admin-only.
See the app permission guide for bootstrap, enabled-mode
role payloads/filters, direct service calls and current MFA enforcement.
Normal authentication runs first. Missing/expired sessions fail as usual;
authorization denial returns 403 forbidden, and missing required second-factor
assurance returns 403 two_factor_required. Auth.RequireAdmin always requires
the protected administrator, even when other routes permit delegated operators.
One distinction that applies to every admin route: user_not_found (404) is returned only when the target user genuinely does not exist. If the database read for the target user fails instead — an outage, a canceled context — the route answers internal_error (500) and logs the cause server-side, so a temporary backend problem is never reported as a missing user. The same rule covers the middleware: session validation or transparent-refresh failures due to backend trouble produce internal_error, never 401 unauthorized — see Error handling.
For the full path/param reference see Routes. For viewing what admins (and everyone else) have done, see Audit Logs.
The curl mutation examples use jq to read the mandatory CSRF bootstrap JSON. Keep the same cookie jar for bootstrap and the following request; the bootstrap is repeated after authentication changes.
Configuration
There's no WithAdmin flag — the existing administrative routes are always
mounted. Access uses the fixed account role when app permissions are off and current
app authorization when on. TwoFactorConfig.DisableAdminTwoFactor opts out of
the default admin requirement; global and per-user requirements still apply.
Custom roles with delegated administrative permissions follow user MFA settings.
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 seed-admin \
--driver postgres --dsn "$DATABASE_URL" --env prod \
--smtp-host smtp.example.com --smtp-from auth@example.comBecause 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.
| Input | Flag | Env var | Notes |
|---|---|---|---|
| Admin email | — | ADMIN_EMAIL | Prompted for interactively if unset and stdin is a TTY; a hard error otherwise. |
| Admin password | — | ADMIN_PASSWORD | Validated 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-tls | SMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_USER, SMTP_PASS, SMTP_TLS | Required unless --env dev (see below) or --skip-mailer-check. The port defaults to 587; an explicit flag overrides SMTP_PORT. Invalid or out-of-range ports fail before seeding. |
| Force non-interactive | --non-interactive | — | Missing ADMIN_EMAIL fails immediately instead of prompting — for CI. |
| Skip the mailer send | --skip-mailer-check | — | Still 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 | --force | — | Without 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 seed-admin \
--driver sqlite --dsn ./dev.db --env devIf 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 use the admin-specific apiRequest(method, path, body) fetch wrapper, which sends X-GoAuth-Session: admin on every request. Use it for bootstrap, login, 2FA, reads, refresh, and logout. 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
| Field | Type | Required |
|---|---|---|
email | string | Required |
password | string | Required |
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. The same default applies to ordinary admin password login, admin OAuth login, and privileged HTTP access. Global/per-user requirements still apply when the admin default is disabled. See Configuration and Security.
Response (200 OK) — with the default admin 2FA policy, a correct password returns a gated challenge, the same shape Login uses:
{
"user": { "...": "role: admin" },
"step": "two_factor",
"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 the challenge 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_admin_session/goauth_admin_refresh. See Two-factor authentication for the full request/response shape.
If DisableAdminTwoFactor is true and neither global nor per-user 2FA is required, login instead returns the ordinary login result with user and sets the session/refresh cookies immediately. Always branch on step: "two_factor" rather than assuming a challenge.
Errors
| Code | Status | Cause |
|---|---|---|
invalid_json | 400 | Malformed body |
invalid_credentials | 401 | Wrong password, unknown email, an OAuth-only account with no password, or correct credentials for a non-admin user — all indistinguishable on purpose |
user_banned | 403 | Account is banned |
email_not_configured | 500 | No mailer configured — see the callout above |
internal_error | 500 | Session creation failure |
curl
# 1. Password step — returns a challenge, not a session
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/auth/admin/login \
-H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txt \
-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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/auth/2fa/verify \
-H "X-CSRF-Token: $CSRF" \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txt -c admin-cookies.txt \
-d '{"challengeId":"c4a1...","code":"482913"}'App and admin cookies have different names, so one cookie jar can hold both sessions. These examples use admin-cookies.txt for convenience. The header selects the admin cookie and CSRF context; it does not grant authorization.
Programmatic (Go)
Go examples on this page import github.com/nazimdjebloun/go-auth/api for operation inputs and results.
result, err := auth.Services().Auth.AdminLogin(ctx, api.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
return
}
if result.RequiresTwoFactor {
// Retain result.TwoFactorChallenge and securely bind result.BindingToken()
// to this client before accepting its emailed code.
verified, err := auth.Services().TwoFactor.Verify(ctx, api.TwoFactorVerifyInput{
ChallengeID: result.TwoFactorChallenge, BindingToken: result.BindingToken(), Code: code,
IP: r.RemoteAddr, UserAgent: r.UserAgent(),
})
if err != nil {
// handle the error and stop before setting session cookies
return
}
auth.SetSessionCookies(w, verified.SessionToken, verified.RefreshToken)
} else {
// Admin 2FA opted out and no global/per-user requirement applies.
auth.SetSessionCookies(w, result.SessionToken, result.RefreshToken)
}Client
const result = await apiRequest("POST", "/auth/admin/login", { email, password });
if (result.step === "two_factor") {
setChallengeId(result.challengeId); // show the code screen
} else {
setAdminUser(result.user); // session cookies are already set
}
// In the code screen, after the admin enters the code:
const verified = await apiRequest("POST", "/auth/2fa/verify", {
challengeId,
code,
});
setAdminUser(verified.user); // session cookies are now setListing and inspecting users
List users — GET /admin/users
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
offset | int | 0 | |
limit | int | 20 | Max 100 |
email | string | — | Exact match |
search | string | — | Matches name or email |
role | string | — | admin or user only — any other value is silently ignored, not applied as a filter |
isBanned | bool | — | true or false — anything else is ignored, not applied as a filter |
isVerified | bool | — | true or false — anything else is ignored, not applied as a filter |
twoFactorEnabled | bool | — | true or false — anything else is ignored, not applied as a filter |
neverLoggedIn | bool | — | true only — last_login_at IS NULL. Registered but never logged in since. |
lastLoginBefore | RFC3339 timestamp | — | last_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. |
orderBy | string | created_at | Or updated_at; an explicitly supplied unsupported value is a 400 |
orderDirection | string | desc | Or asc; an explicitly supplied unsupported value is a 400 |
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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users?limit=20&search=ada&role=user" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
search := "ada"
result, err := auth.Services().Admin.ListUsers(ctx, api.AdminListUsersInput{
ActorID: adminID,
Limit: 20,
Search: &search,
})
// result.UsersClient
const { users } = await apiRequest("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 and limit are ignored. orderBy and orderDirection are also ignored after validation: omitted values use the list defaults, and explicitly unsupported values return 400 invalid_input.
Response (200 OK): { "count": 150 }
curl
curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/count?role=user&isBanned=true" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
roleUser := domain.RoleUser
n, err := auth.Services().Admin.CountUsers(ctx, api.AdminListUsersInput{
ActorID: adminID,
Role: &roleUser,
})Client
const { count } = await apiRequest("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
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
curl
curl -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/users/b3f1... \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
detail, err := auth.Services().Admin.GetUserDetail(ctx, api.GetUserDetailInput{
UserID: userID,
ActorID: adminID, // the admin making this call
})
// detail.User, detail.ActiveSessionCount, detail.ProvidersClient
const detail = await apiRequest("GET", `/admin/users/${userId}`);Creating and managing users
Create a user — POST /admin/users
Creates the user pre-verified — isVerified: true from the moment it's created, no email round-trip. There's no session issued; this is admin provisioning, not a login. Password authentication must be enabled; otherwise this password-based account-creation operation returns 405 method_disabled.
Request body
| Field | Type | Required |
|---|---|---|
email | string | Required |
password | string | Required — must pass SecurityConfig.PasswordPolicy |
name | string | Required — rejected as name_required if blank after trimming |
role | string | Optional — anything other than "admin" becomes "user" |
Response (201 Created) — the created user object, same shape as list.
Errors
| Code | Status | Cause |
|---|---|---|
invalid_json | 400 | Malformed body |
name_required | 400 | Blank name |
weak_password | 400 | Fails PasswordPolicy |
email_already_exists | 409 | An account with that email already exists |
internal_error | 500 | Hashing or database failure |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/users \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-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, api.CreateUserInput{
ActorID: adminID,
Email: "newadmin@myapp.com",
Password: "a very strong password 1",
Name: "New Admin",
Role: "admin",
})Client
const user = await apiRequest("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
| Code | Status | Cause |
|---|---|---|
invalid_role | 400 | role wasn't "user" or "admin" |
user_not_found | 404 | No user with that ID |
last_admin | 400 | Would 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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/users/b3f1.../role \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-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, api.UpdateUserRoleInput{
UserID: userID,
Role: "admin",
ActorID: adminID, // the admin making this call
})Client
await apiRequest("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
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
already_banned | 400 | Ban called on an already-banned user |
not_banned | 400 | Unban called on a user who isn't banned |
last_admin | 400 | Ban would target the only remaining admin |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/users/b3f1.../ban \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Admin.BanUser(ctx, api.BanUserInput{UserID: userID, ActorID: adminID})
err = auth.Services().Admin.UnbanUser(ctx, api.UnbanUserInput{UserID: userID, ActorID: adminID})Client
await apiRequest("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
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
last_admin | 400 | Would delete the only remaining admin |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1... \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Admin.DeleteUser(ctx, api.DeleteUserInput{UserID: userID, ActorID: adminID})Client
await apiRequest("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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/b3f1.../sessions?limit=20" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
sessions, total, err := auth.Services().Admin.ListUserSessions(ctx, api.AdminListUserSessionsInput{
ActorID: adminID, // the admin making this call
UserID: userID,
Limit: 20,
})Client
const { sessions, total } = await apiRequest("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)
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
session_not_found | 404 | Session doesn't exist or doesn't belong to that user |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions/9e2c... \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txt
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Admin.RevokeUserSession(ctx, api.RevokeUserSessionInput{
UserID: userID, SessionID: sessionID, ActorID: adminID,
})
err = auth.Services().Admin.RevokeUserSessions(ctx, api.RevokeUserSessionsInput{
UserID: userID, ActorID: adminID,
}) // all of themClient
await apiRequest("DELETE", `/admin/users/${userId}/sessions/${sessionId}`);
await apiRequest("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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/sessions?ip=203.0.113.9" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
ip := "203.0.113.9"
result, err := auth.Services().Admin.ListSessions(ctx, api.AdminListSessionsInput{
ActorID: adminID,
IP: &ip,
Limit: 20,
})Client
const { sessions } = await apiRequest("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; offset/limit are ignored, and orderBy/orderDirection are validated then ignored.
Response: { "count": 5 }
n, err := auth.Services().Admin.CountSessions(ctx, api.AdminListSessionsInput{
ActorID: adminID, IP: &ip,
})const { count } = await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/users/bulk/ban \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" -H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txt \
-d '{"userIds": ["b3f1...", "9e2c..."]}'Programmatic (Go)
result, err := auth.Services().Admin.BulkBanUsers(ctx, api.BulkUserActionInput{
UserIDs: []string{"b3f1...", "9e2c..."},
ActorID: adminID,
})
// result.Succeeded, result.FailedThe same shape applies to BulkUnbanUsers, BulkDeleteUsers, and BulkRevokeUserSessions.
Client
const result = await apiRequest("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 -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/stats -H "Origin: https://myapp.com" -b admin-cookies.txtProgrammatic (Go)
stats, err := auth.Services().Admin.GetStats(ctx, api.GetAdminStatsInput{
ActorID: adminID,
ActorSessionID: adminSessionID,
})Client
const stats = await apiRequest("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 -H "X-GoAuth-Session: admin" "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.txtProgrammatic (Go)
counts, err := auth.Services().Admin.GetRegistrationTrend(ctx, api.StatsRangeInput{
ActorID: adminID, From: from, To: to,
})Client
const { registrations } = await apiRequest(
"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 -H "X-GoAuth-Session: admin" "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 -H "X-GoAuth-Session: admin" "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.txtProgrammatic (Go)
// Global
counts, err := auth.Services().Admin.GetLoginActivity(ctx, api.LoginActivityInput{
ActorID: adminID, From: from, To: to,
})
// One user
counts, err = auth.Services().Admin.GetLoginActivity(ctx, api.LoginActivityInput{
ActorID: adminID, UserID: &userID, From: from, To: to,
})Client
const { logins } = await apiRequest(
"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. That field is nil when organizations are disabled, and the routes below are not mounted.
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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/orgs?search=acme" \
-H "Origin: https://myapp.com" -b admin-cookies.txtProgrammatic (Go)
search := "acme"
result, err := auth.Services().Org.AdminListOrgs(ctx, api.AdminListOrgsInput{
ActorID: adminID,
Search: &search,
})Client
const { orgs } = await apiRequest("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, and validates then ignores orderBy/orderDirection.
Response: { "count": 42 }
n, err := auth.Services().Org.CountOrgs(ctx, api.AdminListOrgsInput{
ActorID: adminID, Search: &search,
})const { count } = await apiRequest("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 -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/orgs/org-123 \
-H "Origin: https://myapp.com" -b admin-cookies.txtProgrammatic (Go)
org, err := auth.Services().Org.AdminGetOrg(ctx, api.AdminGetOrgInput{
OrgID: "org-123", ActorID: adminID,
})Client
const org = await apiRequest("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 -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/orgs/org-123/members \
-H "Origin: https://myapp.com" -b admin-cookies.txtProgrammatic (Go)
result, err := auth.Services().Org.AdminListOrgMembers(ctx, api.AdminListOrgMembersInput{
OrgID: "org-123", ActorID: adminID,
})Client
const { members } = await apiRequest("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 validates then ignores 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, api.AdminListOrgMembersInput{
OrgID: "org-123", ActorID: adminID,
})const { count } = await apiRequest("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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/user-123/orgs?role=owner&orderBy=member_count&orderDirection=desc" \
-H "Origin: https://myapp.com" -b admin-cookies.txtProgrammatic (Go)
role := domain.OrgRoleOwner
result, err := auth.Services().Org.AdminListUserOrgs(ctx, api.AdminListUserOrgsInput{
ActorID: adminID, UserID: "user-123", Role: &role,
})
// result.Orgs, result.Limit, result.OffsetRole 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("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 validates then ignores ordering.
Response: { "count": 3 }
n, err := auth.Services().Org.AdminCountUserOrgs(ctx, api.AdminListUserOrgsInput{
ActorID: adminID, UserID: "user-123",
})const { count } = await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/orgs/org-123/members \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" -H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txt \
-d '{"userId": "b3f1...", "role": "owner"}'Programmatic (Go)
err := auth.Services().Org.AdminAddMember(ctx, api.AdminAddMemberInput{
OrgID: "org-123", UserID: "b3f1...", Role: domain.OrgRoleOwner, ActorID: adminID,
})Client
await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/orgs/org-123 \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Org.AdminDeleteOrg(ctx, api.AdminOrgActionInput{
OrgID: "org-123", ActorID: adminID,
})Client
await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/orgs/org-123/members/b3f1... \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txt
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/orgs/org-123/members/b3f1.../role \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" -H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-b admin-cookies.txt \
-d '{"role": "owner"}'Programmatic (Go)
err := auth.Services().Org.AdminRemoveMember(ctx, api.AdminRemoveMemberInput{
OrgID: "org-123", UserID: "b3f1...", ActorID: adminID,
})
err = auth.Services().Org.AdminUpdateMemberRole(ctx, api.AdminUpdateMemberRoleInput{
OrgID: "org-123", UserID: "b3f1...", NewRole: domain.OrgRoleOwner, ActorID: adminID,
})Client
await apiRequest("DELETE", "/admin/orgs/org-123/members/b3f1...");
await apiRequest("PATCH", "/admin/orgs/org-123/members/b3f1.../role", { role: "owner" });Platform invites — requires InviteSignup
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.InviteSignup: true; otherwise they 404.
An invite's status is one of pending, accepted, revoked, or expired. Admin listings derive expired from a pending row's expiresAt; token reads report expiry without writing an old snapshot back to the database.
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
| Code | Status | Cause |
|---|---|---|
method_disabled | 405 | InviteSignup is false |
invalid_json | 400 | Malformed body or invalid email |
email_failed | 500 | No mailer configured, or send failure |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/invites \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-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, api.CreateInviteInput{
Email: "ada@example.com",
AdminID: adminID,
})Client
const invite = await apiRequest("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 -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/invites?status=pending&limit=20" \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
invites, err := auth.Services().Invite.ListInvites(ctx, api.ListInvitesInput{
ActorID: adminID,
Status: "pending",
Limit: 20,
})Client
const { invites } = await apiRequest("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, api.ListInvitesInput{ActorID: adminID, Status: "pending"})const { count } = await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/invites/9e2c... \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Invite.RevokeInvite(ctx, api.RevokeInviteInput{InviteID: inviteID, ActorID: adminID})Client
await apiRequest("DELETE", `/admin/invites/${inviteId}`);Resend an invite — POST /admin/invites/{id}/resend
Only pending or expired invites can be resent. Accepted/revoked invites and a lost code-rotation race return invite_already_used without sending email. Resend cannot undo revocation or acceptance.
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
| Code | Status | Cause |
|---|---|---|
invite_not_found | 404 | No invite with that ID |
email_failed | 500 | Send failure |
curl
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/invites/9e2c.../resend \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Invite.ResendInviteEmail(ctx, api.ResendInviteEmailInput{InviteID: inviteID, ActorID: adminID})Client
await apiRequest("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
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/invites/9e2c.../hard \
-H "X-CSRF-Token: $CSRF" \
-c admin-cookies.txt \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
err := auth.Services().Invite.HardDeleteInvite(ctx, api.HardDeleteInviteInput{InviteID: inviteID, ActorID: adminID})Client
await apiRequest("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