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.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. |
| 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@latest 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 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
| 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. 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
| 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
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 yourselfClient
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 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 — anything else falls back to the default |
orderDirection | string | desc | Or 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.txtProgrammatic (Go)
result, err := auth.Services.Admin.ListUsers(ctx, goauth.AdminListUsersInput{
Limit: 20,
Search: goauth.String("ada"),
})
// result.UsersClient
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.txtProgrammatic (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
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
curl
curl https://api.myapp.com/admin/users/b3f1... \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (Go)
detail, err := auth.Services.Admin.GetUserDetail(ctx, goauth.GetUserDetailInput{
UserID: userID,
ActorID: adminID, // the admin making this call
})
// detail.User, detail.ActiveSessionCount, detail.ProvidersClient
const detail = await apiRequest(API_BASE, "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.
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
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
| 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
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
| 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
curl -X PATCH https://api.myapp.com/admin/users/b3f1.../ban \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (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
| Code | Status | Cause |
|---|---|---|
user_not_found | 404 | No user with that ID |
last_admin | 400 | Would delete the only remaining admin |
curl
curl -X DELETE https://api.myapp.com/admin/users/b3f1... \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (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.txtProgrammatic (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)
| 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
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.txtProgrammatic (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 themClient
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.txtProgrammatic (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.FailedThe 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.txtProgrammatic (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.txtProgrammatic (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.txtProgrammatic (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.txtProgrammatic (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.txtProgrammatic (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.txtProgrammatic (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.txtProgrammatic (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.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(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.txtProgrammatic (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
| Code | Status | Cause |
|---|---|---|
method_disabled | 405 | EnableInvite is false |
invalid_json | 400 | Malformed body or invalid email |
email_failed | 500 | No 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.txtProgrammatic (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.txtProgrammatic (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
| Code | Status | Cause |
|---|---|---|
invite_not_found | 404 | No invite with that ID |
email_failed | 500 | Send failure |
curl
curl -X POST https://api.myapp.com/admin/invites/9e2c.../resend \
-H "Origin: https://myapp.com" \
-b admin-cookies.txtProgrammatic (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.txtProgrammatic (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