Simple and easy to use authentication library for Golang.
ezauth can be used as a standalone authentication service or embedded directly into your Go application as a library.
- Features
- Quickstart
- Sessions, Middleware and Helpers
- Sign-in Methods
- Account Security
- Admin and Operations
- API Reference
- Appendix
- Email/Password Authentication (Register, Login), with account lockout after repeated failed attempts
- JWT based sessions (Access & Refresh Tokens, Refresh Token Rotation, automatic reuse/theft detection), plus session listing/revocation ("log out other devices")
- Configurable Password Hashing: bcrypt or Argon2id
- Rate Limiting on authentication endpoints
- OAuth2 Support (Google, GitHub, Facebook, Discord, GitLab, Slack, LinkedIn, Spotify) and custom/OIDC provider registration
- Password Reset and Passwordless (Magic Link) authentication
- SMS OTP (one-time login codes)
- WebAuthn / Passkeys
- Multi-Factor Authentication (TOTP) with recovery codes and trusted-device remembering
- Asymmetric JWT Signing (JWKS) with zero-downtime key rotation
- Guarded Email Change (verify-before-apply, revokes other sessions on confirm)
- Real RBAC (roles/permissions tables) and lightweight multi-tenancy (organizations), fully additive alongside the legacy
User.Rolesfield - Scoped API Keys (limit a key to specific actions)
- Admin impersonation, invitation-based onboarding, admin user management, a persisted audit log, and extensible hooks
- Extended User Profiles (Username, First Name, Last Name, Phone, Avatar, Nickname, Locale, Timezone, Roles, etc.)
- SQLite, PostgreSQL, and MySQL support
- API Key Protection for endpoints
- Built-in Middleware for route protection
- Swagger API Documentation
Get started in two ways: embed ezauth directly in your Go application as a library, or run it as a standalone authentication service.
go get github.com/josuebrunel/ezauthEmbed ezauth directly into your existing Go application.
package main
import (
"fmt"
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/josuebrunel/ezauth"
"github.com/josuebrunel/ezauth/pkg/config"
)
func main() {
// 1. Setup Config
os.Setenv("EZAUTH_API_KEY", "my-api-key")
os.Setenv("EZAUTH_JWT_SECRET", "my-jwt-key-at-least-32-characters-long") // HS256 requires >= 32 chars
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 2. Initialize EzAuth
auth, err := ezauth.New(&cfg, "")
if err != nil {
log.Fatalf("Failed to initialize auth: %v", err)
}
// 3. Run migrations
if err := auth.Migrate(); err != nil {
log.Fatalf("Failed to migrate: %v", err)
}
// Library-mode rollbacks: auth.MigrateDown() reverts every migration
// (empty schema); auth.MigrateRevert() rolls back just the latest one.
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// 4. Add session middleware (handles sessions and user loading)
r.Use(auth.SessionMiddleware)
// 5. Mount Auth Routes
r.Mount("/auth", auth.Handler)
// Protected Route Example
r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
// Retrieve the authenticated user
user, err := auth.GetSessionUser(r.Context())
if err != nil {
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
w.Write([]byte(fmt.Sprintf("Welcome, %s!", user.Email)))
})
http.ListenAndServe(":3000", r)
}You can run ezauth as a separate service that handles authentication for your microservices, instead of embedding it as a library.
-
Configuration: Set environment variables.
export EZAUTH_ADDR=":8080" export EZAUTH_API_KEY="your-master-api-key" export EZAUTH_BASE_URL="http://localhost:8080" export EZAUTH_DB_DIALECT="sqlite3" # or "postgres" or "mysql" (mysql requires 8.0.23+ -- migrations use generated/invisible columns and expression column defaults) export EZAUTH_DB_DSN="auth.db" # for mysql: "user:pass@tcp(localhost:3306)/dbname?parseTime=true" export EZAUTH_DB_SCHEMA="public" # Optional: Database schema (PostgreSQL only) export EZAUTH_JWT_SECRET="super-secret-key-at-least-32-characters-long" # HS256 requires >= 32 chars export EZAUTH_HASHING_ALGORITHM="bcrypt" # Optional; "bcrypt" or "argon2id" export EZAUTH_RATE_LIMIT_ENABLED="true" # Optional; rate limiting on auth endpoints, on by default -- set false to disable export EZAUTH_FORCE_SECURE_COOKIES="false" # Optional; force Secure on session/CSRF cookies regardless of BASE_URL's scheme (set true behind a TLS-terminating reverse proxy) export EZAUTH_TRUST_PROXY_HEADERS="false" # Optional; only set true behind a reverse proxy that sets/overwrites True-Client-IP/X-Real-IP/X-Forwarded-For itself # SMTP (Optional - for Email features) export EZAUTH_SMTP_HOST="smtp.example.com" export EZAUTH_SMTP_PORT="587" export EZAUTH_SMTP_USER="user@example.com" export EZAUTH_SMTP_PASSWORD="password" export EZAUTH_SMTP_FROM="noreply@example.com" # Email Templates (Optional - customize email content) # Uses Go text/template syntax: {{.Link}}, {{.Token}}, {{.Email}} export EZAUTH_EMAIL_PASSWORDLESS_SUBJECT="Magic Link Login" export EZAUTH_EMAIL_PASSWORDLESS_BODY="Click the following link to login: {{.Link}}" export EZAUTH_EMAIL_PASSWORD_RESET_SUBJECT="Password Reset Request" export EZAUTH_EMAIL_PASSWORD_RESET_BODY="Click the following link to reset your password: {{.Link}}" # Pages & Redirects (For Form-based auth) export EZAUTH_REDIRECT_AFTER_LOGIN="/" export EZAUTH_REDIRECT_AFTER_REGISTER="/" export EZAUTH_LOGIN_PAGE_URL="/login" export EZAUTH_REGISTER_PAGE_URL="/register" # OAuth2 (Optional) export EZAUTH_OAUTH2_CALLBACK_URL="http://localhost:3000/callback" # Google export EZAUTH_OAUTH2_GOOGLE_CLIENT_ID="your-google-client-id" export EZAUTH_OAUTH2_GOOGLE_CLIENT_SECRET="your-google-client-secret" export EZAUTH_OAUTH2_GOOGLE_REDIRECT_URL="http://localhost:8080/auth/oauth2/google/callback" export EZAUTH_OAUTH2_GOOGLE_SCOPES="email,profile" # GitHub export EZAUTH_OAUTH2_GITHUB_CLIENT_ID="your-github-client-id" export EZAUTH_OAUTH2_GITHUB_CLIENT_SECRET="your-github-client-secret" export EZAUTH_OAUTH2_GITHUB_REDIRECT_URL="http://localhost:8080/auth/oauth2/github/callback" export EZAUTH_OAUTH2_GITHUB_SCOPES="user:email" # Facebook export EZAUTH_OAUTH2_FACEBOOK_CLIENT_ID="your-facebook-client-id" export EZAUTH_OAUTH2_FACEBOOK_CLIENT_SECRET="your-facebook-client-secret" export EZAUTH_OAUTH2_FACEBOOK_REDIRECT_URL="http://localhost:8080/auth/oauth2/facebook/callback" export EZAUTH_OAUTH2_FACEBOOK_SCOPES="email" # Discord export EZAUTH_OAUTH2_DISCORD_CLIENT_ID="your-discord-client-id" export EZAUTH_OAUTH2_DISCORD_CLIENT_SECRET="your-discord-client-secret" export EZAUTH_OAUTH2_DISCORD_REDIRECT_URL="http://localhost:8080/auth/oauth2/discord/callback" export EZAUTH_OAUTH2_DISCORD_SCOPES="identify,email" # GitLab export EZAUTH_OAUTH2_GITLAB_CLIENT_ID="your-gitlab-client-id" export EZAUTH_OAUTH2_GITLAB_CLIENT_SECRET="your-gitlab-client-secret" export EZAUTH_OAUTH2_GITLAB_REDIRECT_URL="http://localhost:8080/auth/oauth2/gitlab/callback" export EZAUTH_OAUTH2_GITLAB_SCOPES="read_user" # Slack export EZAUTH_OAUTH2_SLACK_CLIENT_ID="your-slack-client-id" export EZAUTH_OAUTH2_SLACK_CLIENT_SECRET="your-slack-client-secret" export EZAUTH_OAUTH2_SLACK_REDIRECT_URL="http://localhost:8080/auth/oauth2/slack/callback" export EZAUTH_OAUTH2_SLACK_SCOPES="openid,email" # LinkedIn export EZAUTH_OAUTH2_LINKEDIN_CLIENT_ID="your-linkedin-client-id" export EZAUTH_OAUTH2_LINKEDIN_CLIENT_SECRET="your-linkedin-client-secret" export EZAUTH_OAUTH2_LINKEDIN_REDIRECT_URL="http://localhost:8080/auth/oauth2/linkedin/callback" export EZAUTH_OAUTH2_LINKEDIN_SCOPES="openid,profile,email" # Spotify export EZAUTH_OAUTH2_SPOTIFY_CLIENT_ID="your-spotify-client-id" export EZAUTH_OAUTH2_SPOTIFY_CLIENT_SECRET="your-spotify-client-secret" export EZAUTH_OAUTH2_SPOTIFY_REDIRECT_URL="http://localhost:8080/auth/oauth2/spotify/callback" export EZAUTH_OAUTH2_SPOTIFY_SCOPES="user-read-email,user-read-private"
-
Build and Run: Build the binary from
cmd/ezauthapi/main.go.go build -o ezauthapi ./cmd/ezauthapi
Then, run the compiled binary — this one binary handles migrations, admin bootstrapping, and serving:
./ezauthapi # migrates, then serves (the default) ./ezauthapi migrate up # or run migrations as their own step; also `down` (roll back everything, requires -yes) / `revert` (roll back one) ./ezauthapi create-admin -email=admin@example.com -password=<a-strong-password> # bootstrap an admin user (idempotent, -role defaults to "admin")
Run
create-adminbefore you need any admin/RBAC/org/impersonation route (/auth/api/admin/*,/auth/api/impersonate, ...) — by defaultHandlerdenies all of them (401/403) until at least one user holds theadminRBAC role. See Admin Authorization.
Core building blocks for the library API: cookie-based sessions, route protection middlewares, and helper functions for handlers and templates.
When using the Form-based handlers, ezauth manages sessions using HTTP-only cookies via the scs session manager. The cookie name is ezauthsess.
Note
Handler.Session defaults to scs's in-memory store (scs.New()'s default): sessions don't survive a process restart, and aren't shared across nodes in a multi-node/load-balanced deployment (each instance has its own independent session state). This is fine for a single-instance deployment or local development, but for anything else, swap in one of scs's pluggable external stores (Redis, Postgres, MySQL, ...) by setting auth.Session.Store after constructing the handler:
auth := handler.New(svc, "auth")
auth.Session.Store = redisstore.New(pool) // or any other scs.Store implementationThe rate limiter (EZAUTH_RATE_LIMIT_*) is separate and has no pluggable store: its budget is an in-memory map, single-instance by design -- in a multi-node deployment, each node enforces its own independent budget rather than a shared one. If you need a shared, cross-node rate limit, put one in front of ezauth at the load balancer/gateway layer instead.
Inside the session, the Access Token and Refresh Token are stored under the key tokens.
You can retrieve them in your application using the helper method:
tokens, err := auth.GetSessionTokens(ctx)
if err == nil {
accessToken := tokens["access_token"]
refreshToken := tokens["refresh_token"]
// ...
}You can retrieve the full user object from the session using auth.GetSessionUser(ctx).
Important
You MUST mount the session middleware on your router for this to work.
// 1. Mount session middleware
r.Use(auth.SessionMiddleware)
// 2. In your handler
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
if !auth.IsAuthenticated(r.Context()) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, _ := auth.GetSessionUser(r.Context())
fmt.Println("User:", user.Email)
})When using form-based handlers, errors and success messages are stored as flash messages in the session. Flash messages are one-time messages that are automatically cleared after being read.
r.Get("/login", func(w http.ResponseWriter, r *http.Request) {
// Get flash messages (auto-cleared after read)
errorMsg := auth.GetErrorMessage(r.Context())
successMsg := auth.GetSuccessMessage(r.Context())
// Pass to template for display
data := map[string]string{
"Error": errorMsg,
"Success": successMsg,
}
tmpl.Execute(w, data)
})When using the form-based handlers (e.g., POST /auth/login), ezauth automatically enforces CSRF protection using filippo.io/csrf/gorilla. Unlike the classic HMAC-signed-token CSRF pattern, this library needs no secret key at all (see below) — EZAUTH_CSRF_SECRET is accepted for config-shape compatibility but currently has no effect on CSRF protection.
Note on Tokens vs Headers:
This library relies entirely on modern browser Fetch Metadata headers (e.g. Sec-Fetch-Site, Origin) to enforce same-origin requests dynamically, mirroring the upcoming Go 1.25 standard library CSRF protections.
Because of this, hidden CSRF tokens in your HTML forms are completely optional and ignored during validation. However, if you are integrating with frontend frameworks or legacy systems that expect a token to be present, ezauth provides helpers to seamlessly generate dummy tokens to satisfy those requirements:
import "github.com/josuebrunel/ezauth"
// In your custom handler (ensure it's wrapped with the same CSRF middleware as ezauth)
r.Get("/my-custom-login", func(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
// Generate a pre-built <input type="hidden"> field
"csrfField": ezauth.CSRFTemplateField(r),
// Or get the raw string if you need it for AJAX headers (X-CSRF-Token)
"csrfToken": ezauth.CSRFToken(r),
}
tmpl.Execute(w, data)
})Enforcement rules: GET/HEAD/OPTIONS requests are always allowed (CSRF only matters for state-changing methods). For everything else: a Sec-Fetch-Site of same-origin or none is allowed, any other value (cross-site, same-site, cross-origin, ...) is rejected. If Sec-Fetch-Site is absent entirely (older browsers, or a non-browser client like curl/an SDK), the request falls back to comparing the Origin header's host against the request's Host header — a match is allowed, a mismatch is rejected, and a request with neither header is allowed outright (assumed same-origin or non-browser). This is exercised end-to-end in pkg/handler/csrf_test.go.
If your frontend is served from a different origin than ezauth itself (so browsers legitimately send Sec-Fetch-Site: cross-site), set EZAUTH_CSRF_TRUSTED_ORIGINS to a comma-separated list of allowed Origin values (e.g. https://app.example.com) to exempt them from this check. Leave it unset for same-origin deployments, including behind a reverse proxy — those already pass the checks above without needing a trusted-origin entry.
Note
If you are using the JSON API endpoints (/auth/api/*) instead of the web forms, CSRF is disabled automatically since they use standard JWT Bearer Auth without cookies.
ezauth provides several "plug and play" middlewares to protect your routes and manage user sessions. These are available directly on the EzAuth instance.
Usage: r.Use(auth.SessionMiddleware)
This is the recommended middleware for most applications. It combines session management and user loading.
- Loads and saves session data (cookies).
- Populates
GetSessionUser(ctx)for downstream handlers.
Usage: r.Use(auth.LoginRequiredMiddleware)
Protects routes by requiring authentication.
- Browser requests: Redirects to the configured
EZAUTH_LOGIN_PAGE_URL. - API requests: Returns
401 Unauthorized.
Usage: r.Use(auth.LoadUserMiddleware)
Loads the user into the context without managing the session itself. Use this if you are using auth.Handler.Session.LoadAndSave manually or have a custom session setup.
Usage: r.Use(auth.AuthMiddleware)
Protects API routes using JWT Bearer tokens in the Authorization header.
- Validates the token signature.
- Sets the user ID in the context.
ezauth provides package-level helper functions for convenient access to authentication context, useful in handlers or templates.
Important
Middleware prerequisites vary per helper: SessionMiddleware populates the session/cookie-based helpers (GetUser, GetSessionTokens, cookie-mode impersonation) and AuthMiddleware the JWT/Bearer ones (GetUserID, GetImpersonatorID). GetUserID, GetUser, and IsAuthenticated work under any of SessionMiddleware, LoadUserMiddleware, or AuthMiddleware.
import "github.com/josuebrunel/ezauth"
func MyHandler(w http.ResponseWriter, r *http.Request) {
// Check if authenticated
if ezauth.IsAuthenticated(r.Context()) {
// ...
}
// Get User ID (works with both Session and JWT auth)
userID, err := ezauth.GetUserID(r.Context())
// Get User Object (requires LoadUserMiddleware or SessionMiddleware)
user, err := ezauth.GetUser(r.Context())
if err == nil {
// Check for role
if user.HasRole("admin") {
// ...
}
// Get Metadata with type safety
if theme, ok := models.GetMeta[string](user, "theme"); ok {
// use theme
}
}
// Get the session's access/refresh tokens (requires SessionMiddleware)
tokens, err := ezauth.GetSessionTokens(r.Context())
if err == nil {
accessToken := tokens["access_token"] // ...
}
// Detect an impersonation session, regardless of transport
// (cookie session or Bearer/JWT "act" claim)
if adminID, ok := ezauth.CurrentImpersonatorID(r.Context()); ok {
// acting as another user on behalf of adminID
}
// Get the "current organization" resolved by OrgLoaderMiddleware
org, err := ezauth.GetSessionOrg(r.Context())
}The User struct includes helper methods for common operations:
HasRole(role string) bool: Checks if the user has a specific role.HasAnyRole(roles ...string) bool: Checks if the user has any of the given roles.HasAllRoles(roles ...string) bool: Checks if the user has all of the given roles.GetRoles() []string: Returns the user's roles as a slice.AddRole(role string): Appends a role (avoids duplicates).RemoveRole(role string): Removes a role from the list.
FullName() string: Returns the user's first and last name combined.DisplayName() string: Returns the best available name (FullName > Username > email local-part).
IsOAuth() bool: Returns true if the user signed up via an OAuth2 provider.IsLocal() bool: Returns true if the user signed up with email/password.
Sanitize(): Clears sensitive fields (e.g.,PasswordHash) before serialization.
GetMeta[T any](user, key) (T, bool): Retrieves a value fromUserMetadatawith type casting.SetMeta(key, value): Sets a value inUserMetadata.GetAppMeta[T any](user, key) (T, bool): Retrieves a value fromAppMetadata.SetAppMeta(key, value): Sets a value inAppMetadata.
Email/password, magic-link (passwordless), and password-reset flows are built in via the endpoints listed in the API Reference. The sections below cover the additional sign-in methods.
OAuth2 is supported via built-in provider presets (configured with EZAUTH_OAUTH2_<NAME>_* environment variables, see the Standalone Service env block above) or by registering arbitrary custom/OIDC providers as shown below.
Important
OAuth2 auto-linking requires the provider to return email_verified: true in the user info response. If a provider does not return this field (or returns false), the user will be prompted to log in with their existing password rather than being automatically linked. This prevents account takeover via unverified email addresses.
You can register arbitrary custom providers dynamically via environment variables (Standalone-service mode) or in Go code (Library mode) as shown below.
- Add your provider's name to
EZAUTH_OAUTH2_PROVIDERS(comma-separated). - Configure prefix variables for each provider (
EZAUTH_OAUTH2_<NAME>_):CLIENT_ID,CLIENT_SECRET,REDIRECT_URL(required)SCOPES(optional, comma-separated)- Either
ISSUER_URL(for automatic OIDC discovery) or manual endpoint parameters (AUTH_URL,TOKEN_URL,USERINFO_URL,ID_FIELD(defaultid),EMAIL_FIELD(defaultemail)).
You can register custom providers programmatically using the RegisterOAuth2Provider API. We ship pre-made presets (Discord, Slack, GitLab) and a generic OIDC discovery helper in the optional github.com/josuebrunel/ezauth/pkg/service/providers package:
import (
"github.com/josuebrunel/ezauth/pkg/service/providers"
)
// 1. OIDC Discovery
oktaProvider, err := providers.OIDC(ctx, "https://your-domain.okta.com", "client-id", "client-secret", "http://localhost:8080/auth/oauth2/okta/callback", []string{"openid", "profile", "email"})
if err == nil {
auth.RegisterOAuth2Provider("okta", oktaProvider)
}
// 2. Out-of-the-box Preset
discordProvider := providers.Discord("client-id", "client-secret", "http://localhost:8080/auth/oauth2/discord/callback")
auth.RegisterOAuth2Provider("discord", discordProvider)ezauth supports SMS-based one-time-password login, mirroring the email-based passwordless (magic link) flow: a 6-digit code is sent to a phone number, and submitting it back logs the user in (or registers a new phone-only account, exactly like an unrecognized email does for passwordless).
Important
SMS sending requires a Twilio-compatible provider — set EZAUTH_SMS_TWILIO_ACCOUNT_SID, EZAUTH_SMS_TWILIO_AUTH_TOKEN, and EZAUTH_SMS_TWILIO_FROM. Without them, ezauth logs a warning and uses a mock sender that doesn't actually send anything (useful for local development/tests). Phone numbers are enforced unique across accounts at the database level (a partial/filtered unique index; empty phones are exempt).
Note
Verifying a code flips the account's contact flag: a successful SMSOTPVerify marks the phone verified, and a successful first magic-link (passwordless) login marks the email verified — so the temporary, unverified account created on first contact becomes verified on first successful login.
err := auth.Service.SMSOTPRequest(ctx, service.RequestSMSOTP{Phone: "+15551234567"})
tokens, err := auth.Service.SMSOTPVerify(ctx, service.RequestSMSOTPVerify{
Phone: "+15551234567",
Code: "123456",
})curl -X POST https://your-host/auth/api/sms-otp/request -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"phone": "+15551234567"}'
curl -X POST https://your-host/auth/api/sms-otp/verify -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"phone": "+15551234567", "code": "123456"}'For form-based (cookie) clients, POST /auth/sms-otp/request (field phone) and POST /auth/sms-otp/verify (fields phone, code) work the same way and set auth cookies on success.
Set EZAUTH_SMS_OTP_BODY (default Your verification code is: {{.Code}}) to customize the SMS message template; {{.Code}} and {{.Phone}} are available.
ezauth supports WebAuthn/FIDO2 passkey registration and login, as an alternative or complement to password/MFA login. Login is discoverable (usernameless): the browser's platform UI lets the user pick which passkey to use, so no prior email/username is required.
Important
WebAuthn is disabled unless EZAUTH_WEBAUTHN_RP_ID and EZAUTH_WEBAUTHN_RP_ORIGINS are both set. RPID is the effective domain (e.g. example.com, no scheme/port); RPOrigins is a comma-separated list of allowed origins (e.g. https://example.com). WebAuthn ceremonies always require client-side JavaScript (navigator.credentials.create()/.get()), regardless of whether the rest of your app uses cookies or Bearer tokens — there is no plain-HTML-form equivalent.
// user must already be authenticated.
creation, sessionKey, err := auth.WebauthnBeginRegistration(ctx, user)
// Send creation (as JSON) to the browser to call navigator.credentials.create(),
// and keep sessionKey to pass to the finish step (e.g. as a query param, or
// stashed in the user's session).
// r is the incoming HTTP request whose body is the browser's raw
// navigator.credentials.create() response, forwarded verbatim.
cred, err := auth.WebauthnFinishRegistration(ctx, user, sessionKey, r, "YubiKey 5")assertion, sessionKey, err := auth.WebauthnBeginLogin(ctx)
// Send assertion (as JSON) to the browser to call navigator.credentials.get().
// r is the incoming HTTP request whose body is the browser's raw
// navigator.credentials.get() response, forwarded verbatim.
user, tokens, err := auth.WebauthnFinishLogin(ctx, sessionKey, r)creds, err := auth.WebauthnCredentials(ctx, user.ID)
err = auth.WebauthnDeleteCredential(ctx, user, credentialRecordID)Both JSON API (Bearer/API-key) and form (cookie session) variants are available, mirroring MFA. The JSON variants return raw tokens; the form variants set auth cookies directly and never expose tokens to client-side JavaScript.
# Registration (requires the user's own Bearer token):
curl -X POST https://your-host/auth/api/webauthn/register/begin -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
# -> pass the "response" fields to navigator.credentials.create() in the browser, then:
curl -X POST "https://your-host/auth/api/webauthn/register/finish?session_key=<key>&name=YubiKey" \
-H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '<navigator.credentials.create() result>'
# Login (no prior auth required):
curl -X POST https://your-host/auth/api/webauthn/login/begin -H "X-API-Key: your-api-key"
# -> pass the "response" fields to navigator.credentials.get() in the browser, then:
curl -X POST "https://your-host/auth/api/webauthn/login/finish?session_key=<key>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '<navigator.credentials.get() result>'
# Manage credentials (requires the user's own Bearer token):
curl https://your-host/auth/api/webauthn/credentials -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/webauthn/credentials/<id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"The cookie-mode equivalents live at /auth/webauthn/register/begin, /auth/webauthn/register/finish, /auth/webauthn/login/begin, /auth/webauthn/login/finish, /auth/webauthn/credentials, and /auth/webauthn/credentials/{id} — same request/response shapes, but authenticated via the session cookie (and CSRF token) instead of a Bearer token, and login/finish sets auth cookies and returns {"redirect": "..."} instead of raw tokens.
Second-factor and hardening features: MFA, session revocation, lockout, guarded email changes, and asymmetric JWT signing.
Every bearer-style token ezauth issues — refresh tokens, password-reset and passwordless magic links, API keys, MFA pre-auth tokens, MFA recovery codes, SMS OTP codes, trusted-device tokens, invitations, and email-change confirmation links — is stored and looked up by its SHA-256 hash, never the raw value. These are all high-entropy random values (not passwords), so an unsalted hash is sufficient: there's no meaningful dictionary/rainbow-table attack surface once the input space is that large. This means a database-read compromise (a backup leak, a misconfigured read replica, an overprivileged reporting user, ...) doesn't hand over directly usable credentials for every user — an attacker would need the raw value itself, which only ever appears in the response/email/link sent to its owner and is never recoverable from what's stored.
This is transparent to callers: APIKeyCreate, TokenCreate, InvitationCreate, etc. still return/email the raw value exactly as before — only the on-disk representation changed. If you're reading ezauth_tokens.token directly (e.g. for an admin tool or a data migration of your own), keep in mind it's a hash, not the original value.
ezauth supports TOTP-based MFA (RFC 6238) — the standard "authenticator app" second factor (Google Authenticator, Authy, 1Password, etc.).
Once a user enables MFA, a successful password login no longer returns session tokens directly: it returns a short-lived mfa_token (5 minutes) that must be exchanged for real session tokens via a TOTP or recovery code. This "step-up" flow is enforced by CompleteBasicLogin, which Login/FormLogin call internally.
// user must already be authenticated.
enroll, err := auth.MFAEnroll(ctx, user)
// enroll.Secret is the raw base32 secret; enroll.OTPAuthURL is an otpauth:// URI
// you can render as a QR code for the user to scan. MFA is NOT enabled yet.
// After the user scans the QR code and enters a code from their app:
recoveryCodes, err := auth.MFAConfirm(ctx, user, code)
// MFA is now enabled. recoveryCodes is a slice of one-time-use plaintext codes —
// show them to the user once; ezauth only ever stores their hashes.// user is already password-checked. Pass a trusted-device token if the client
// supports "remember this device" (see below), or "" otherwise.
loginResp, err := auth.CompleteBasicLogin(ctx, user, deviceToken)
if loginResp.MFARequired {
// Prompt for a TOTP/recovery code, then:
user, tokens, deviceToken, err := auth.MFALoginVerify(ctx, loginResp.MFAToken, code, rememberDevice)
// tokens.AccessToken / tokens.RefreshToken authenticate the user.
} else {
// loginResp.TokenResponse is already a full session — no MFA configured.
}err := auth.MFADisable(ctx, user, code) // accepts a TOTP or recovery code# Login — returns either tokens directly, or {"mfa_required": true, "mfa_token": "..."}.
curl -X POST https://your-host/auth/api/login -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"email": "user@example.com", "password": "..."}'
# Complete the step-up login:
curl -X POST https://your-host/auth/api/mfa/login/verify -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"mfa_token": "<mfa-token>", "code": "123456"}'
# Enrollment (requires the user's own Bearer token):
curl -X POST https://your-host/auth/api/mfa/enroll -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/mfa/confirm -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"code": "123456"}'For form-based (cookie) clients, POST /auth/mfa/enroll, /auth/mfa/confirm, and /auth/mfa/disable work the same way against the logged-in session user; FormLogin redirects to Pages.MFAVerify when a step-up is required, stashing the pending mfa_token server-side in the session (never exposed to the client) until POST /auth/mfa/login/verify completes it. Use auth.GetMFAEnrollment(ctx) to read back the pending secret/QR URL for rendering the enrollment page.
Set EZAUTH_MFA_ISSUER (default EzAuth) to control the issuer name shown in authenticator apps, and EZAUTH_MFA_VERIFY_PAGE_URL (default /mfa/verify) to point at your own MFA code-entry page.
Completing MFA step-up can optionally mark the current device as trusted, so future logins from it skip MFA entirely until the trust expires (EZAUTH_TRUSTED_DEVICE_TTL, default 30 days).
// At MFA step-up (pass rememberDevice=true):
user, tokens, deviceToken, err := auth.MFALoginVerify(ctx, loginResp.MFAToken, code, true)
// Store deviceToken (client-side) and send it back on the next login attempt.
// On a later password login, pass the stored deviceToken:
loginResp, err := auth.CompleteBasicLogin(ctx, user, deviceToken)
// If deviceToken is still valid, loginResp.MFARequired is false — no step-up needed.
// Managing trusted devices:
devices, err := auth.TrustedDevices(ctx, user.ID)
err = auth.RevokeTrustedDevice(ctx, user, devices[0].ID)Under the hood these build on the TrustDevice/IsTrustedDevice primitives, which MFALoginVerify and CompleteBasicLogin call for you. You can also call auth.TrustDevice(ctx, user, name) directly to mint a trusted-device token for a device out-of-band.
For the JSON API, a client sends its stored device token back via the X-Device-Token request header on POST /auth/api/login, and POST /auth/api/mfa/login/verify accepts "remember_device": true in its body, returning a device_token field to store. For form-based (cookie) clients, this is fully automatic: FormLogin reads the trusted-device cookie itself, and checking a remember_device field on the MFA verification form makes FormMFALoginVerify set that cookie (EZAUTH_TRUSTED_DEVICE_COOKIE_NAME, default ezauth_device) directly — no extra wiring needed.
List/revoke trusted devices via GET/DELETE /auth/api/trusted-devices[/{id}] (Bearer) or GET/DELETE /auth/trusted-devices[/{id}] (cookie session).
Every refresh token issued to a user (one per login, across devices/clients) is a session. Let users see and remotely revoke their own active sessions — e.g. a "log out other devices" account-security page.
sessions, err := auth.Sessions(ctx, user.ID)
// []service.SessionInfo{ID, CreatedAt, ExpiresAt}, most recent first.
err = auth.RevokeSession(ctx, user, sessions[0].ID) // log out one device
err = auth.RevokeAllSessions(ctx, user, currentID) // log out other devices, keep currentID
err = auth.RevokeAllSessions(ctx, user, "") // log out everywhereFor the JSON API: GET /auth/api/sessions lists sessions, DELETE /auth/api/sessions/{id} revokes one, and DELETE /auth/api/sessions?except={id} revokes all but the session named by except (omit except to log out everywhere). Cookie clients use the same routes under /auth/sessions[...].
Needs no code or configuration — it's automatic. Every refresh token is tagged with a rotation family_id, carried forward across TokenRefresh rotations. If an already-rotated-out (revoked) refresh token is ever replayed, that's a strong signal it was stolen and the legitimate client has since rotated past it — so TokenRefresh responds by revoking every other active token in that family in one bulk operation, not just rejecting the replayed one. In practice: if an attacker steals a refresh token and uses it after the real client already refreshed past it, both the attacker's and the legitimate client's sessions get logged out, forcing a fresh login.
UserAuthenticate enforces the IsActive column as a login gate and counts consecutive failed password attempts: after EZAUTH_ACCOUNT_LOCKOUT_MAX_ATTEMPTS (default 5) in a row, the account is locked — IsActive is cleared — for EZAUTH_ACCOUNT_LOCKOUT_DURATION (default 15 minutes), then automatically unlocked on the next login attempt after that window passes. A successful login resets the counter immediately (and with it, the backoff described below). Set EZAUTH_ACCOUNT_LOCKOUT_ENABLED=false to stop counting/auto-locking failed attempts while still enforcing IsActive for accounts deactivated some other way (e.g. an administrative suspension).
The counter itself is not reset by auto-unlock, only by a successful login: each additional EZAUTH_ACCOUNT_LOCKOUT_MAX_ATTEMPTS-sized batch of failures doubles the lockout duration (capped at 24 hours). A fixed-duration lockout keyed purely on the account would otherwise be a repeatable, indefinite denial-of-service — an attacker who only wants to deny a victim access, not actually guess the password, could keep the account locked forever by sending MAX_ATTEMPTS wrong guesses every LOCKOUT_DURATION once each lockout auto-expires.
_, err := auth.Service.UserAuthenticate(ctx, service.RequestBasicAuth{Email: email, Password: password})
if errors.Is(err, service.ErrAccountLocked) {
// Too many failed attempts recently; try again after the lockout window.
} else if errors.Is(err, service.ErrAccountDisabled) {
// IsActive is false for a reason other than lockout (no auto-expiry).
}Login/FormLogin themselves always return a generic "invalid credentials" message in both JSON API and form-based (cookie) modes, regardless of cause (no such account, wrong password, locked, or disabled) — ErrAccountLocked/ErrAccountDisabled only ever apply to an account that exists, so surfacing them verbatim to an anonymous caller would leak account existence/lockout state, the exact enumeration vector shown in the snippet above being guarded against server-side. Build your own handler on top of Service.UserAuthenticate (as shown above) if you want to surface the specific reason to an already-authenticated context (e.g. an account-settings page).
By default ezauth signs access tokens with symmetric HS256 (EZAUTH_JWT_SECRET) — any resource server that verifies tokens itself must hold that same secret. Set EZAUTH_JWT_ALGORITHM=RS256 or EZAUTH_JWT_ALGORITHM=EdDSA to sign asymmetrically instead: ezauth keeps the private key, and independent resource servers verify tokens against the public key published at GET /.well-known/jwks.json — no shared secret required.
EZAUTH_JWT_ALGORITHM=RS256
EZAUTH_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
EZAUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"Both keys are PEM encoded (PKCS8 for the private key, PKIX for the public key — the two files openssl genpkey/openssl pkey produce by default). EdDSA (Ed25519) works the same way. A resource server fetches the JWKS and verifies the RS256/EdDSA-signed token itself — no call back to ezauth needed. The JWKS route is mounted on the ezauth handler's router root (it's not inside the /auth route group): in standalone mode that's the conventional GET /.well-known/jwks.json at your server root; in library mode it sits under wherever you mounted the handler — e.g. GET /auth/.well-known/jwks.json if you r.Mount("/auth", auth.Handler).
Key rotation: each key gets a kid (key ID) — either an explicit EZAUTH_JWT_KEY_ID, or, left unset, a stable hash ezauth derives from the public key automatically. To rotate without invalidating tokens already issued under the outgoing key, move its public key/kid to EZAUTH_JWT_PREVIOUS_PUBLIC_KEY/EZAUTH_JWT_PREVIOUS_KEY_ID and set EZAUTH_JWT_PRIVATE_KEY/PUBLIC_KEY/KEY_ID to the new key: new tokens sign under the new key, while tokens already signed under the previous one keep verifying (and both keys are published in the JWKS) until they naturally expire (access tokens are short-lived — EZAUTH_JWT_ACCESS_TOKEN_TTL, 15 minutes by default). Drop PREVIOUS_* once nothing outstanding still needs it.
set := auth.JWKS() // service.JWKSet{Keys: []service.JWK} — empty for the default HS256 modeClaims: every access token carries a jti (a random unique ID, the prerequisite for any future denylist-based revocation) alongside the usual sub/email/exp/iat. Set EZAUTH_JWT_ISSUER/EZAUTH_JWT_AUDIENCE to also stamp iss/aud and have AuthMiddleware enforce them (jwt.WithIssuer/jwt.WithAudience) — useful when multiple services share a signing key, so a token minted for one can't authenticate to another. Both are unset by default: no claims added, no enforcement, matching every prior release.
By default an API key (via APIKeyMiddleware) grants the same access as the full account. APIKeyCreate can limit a key to a specific set of scopes, enforced per-route with RequireAPIKeyScope, layered on top of APIKeyMiddleware's existing all-or-nothing group-level gate.
token, err := auth.APIKeyCreate(ctx, user.ID, []string{"posts:write"}, 0) // ttl 0 = Cfg.APIKeyDefaultTTL (10 years by default)
// token.Token is the raw key value — store/display it now, it can't be recovered later.
keys, err := auth.APIKeysList(ctx, user.ID) // []service.APIKeyInfo — raw key omitted, shown only once above
err = auth.APIKeyRevoke(ctx, user.ID, token.ID) // fails with service.ErrAPIKeyNotFound if the key isn't user'sr.Use(auth.Handler.APIKeyMiddleware) // group-level gate: any valid key gets past this
r.With(auth.RequireAPIKeyScope("posts:write")).Post("/posts", createPostHandler)Warning
An unscoped key has full access, not restricted access. APIKeyCreate(ctx, userID, nil, 0) (or {"scopes": []} over the API) does not create a key with no permissions — it creates a key that passes every RequireAPIKeyScope check unconditionally, identically to a key issued before scoping existed. If you want a key restricted to nothing, don't gate the routes it should never reach behind RequireAPIKeyScope at all — put them behind APIKeyMiddleware only for keys that are meant to be unrestricted, and always pass an explicit non-empty scopes list for any key that should be limited. The master EZAUTH_API_KEY config key has no associated Token at all, so it's always unscoped/full-access too, regardless of any RequireAPIKeyScope check.
Key lifetime and master-key rotation: APIKeyCreate's ttl parameter (0 = EZAUTH_API_KEY_DEFAULT_TTL, 10 years by default) bounds an individual key's lifetime. The shared EZAUTH_API_KEY config key itself has no per-key expiry, but APIKeyMiddleware also accepts EZAUTH_PREVIOUS_API_KEY during a rotation window — mirroring EZAUTH_JWT_PREVIOUS_PUBLIC_KEY's pattern: move the outgoing master key there and set EZAUTH_API_KEY to the new one, then drop EZAUTH_PREVIOUS_API_KEY once nothing outstanding still needs it.
Self-service, like Sessions: keys are always scoped to the caller's own account, no admin path or {id}-for-whose param.
# Create a key (requires the user's own Bearer token) — scopes and ttl_seconds
# are both optional; omit/empty scopes for unscoped, omit/0 ttl_seconds for the
# EZAUTH_API_KEY_DEFAULT_TTL default (10 years):
curl -X POST https://your-host/auth/api/api-keys -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"scopes": ["posts:write"], "ttl_seconds": 2592000}'
# -> the response's "token" field is the raw key value — store/display it now, it can't be recovered later.
# List your own keys:
curl https://your-host/auth/api/api-keys -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
# Revoke one of your own keys:
curl -X DELETE https://your-host/auth/api/api-keys/<key-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"For form-based (cookie) clients, the same three operations are available against the logged-in session user at POST/GET /auth/api-keys and DELETE /auth/api-keys/{id} (note: no /api segment — those are the form routes, under the same CSRF-protected group as login).
Changing the account email is treated as a distinct, security-sensitive operation, the same way password reset already is — not just another field on a generic profile update. Initiating a change requires the current password; the new address only takes effect once it's verified via a link sent to it (the old address stays active and receiving mail in the meantime); and the old address gets a notice that a change was requested, since an unrequested email change is a classic account-takeover step. Confirming the change also revokes every other session, matching how password reset already treats a successful reset.
err := auth.Service.EmailChangeRequest(ctx, user, service.RequestEmailChange{
CurrentPassword: "their-current-password",
NewEmail: "new-address@example.com",
})
// Later, from the link sent to the *new* address:
updated, err := auth.Service.EmailChangeConfirm(ctx, tokenFromLink)
// updated.Email is now the new address; every other session was just revoked.# Request the change (requires the user's own Bearer token):
curl -X POST https://your-host/auth/api/email-change/request -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"current_password": "...", "new_email": "new-address@example.com"}'
# Confirm from the link sent to the new address (no auth required):
curl "https://your-host/auth/api/email-change/confirm?token=<token>" -H "X-API-Key: your-api-key"
# Or, safer -- POST with the token in the body instead of the URL, since a
# query-string token lands in access logs, browser history, and Referer
# headers. Use this if you control the confirmation flow yourself (e.g. an
# intermediate page that extracts ?token=... from its own URL and POSTs it)
# rather than just forwarding the emailed GET link directly:
curl -X POST https://your-host/auth/api/email-change/confirm -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"token": "<token>"}'For form-based (cookie) clients, POST /auth/email-change/request (fields current_password, new_email) works the same way against the logged-in session user, and GET /auth/email-change/confirm?token=... applies the change, clears the session (since it was just revoked along with every other one), and redirects to Pages.Login -- or POST /auth/email-change/confirm with token as a form field, for the same reason as above. Both responses set Referrer-Policy: no-referrer.
Set EZAUTH_EMAIL_CHANGE_SUBJECT/EZAUTH_EMAIL_CHANGE_BODY to customize the verification email sent to the new address, and EZAUTH_EMAIL_CHANGE_NOTIFY_SUBJECT/EZAUTH_EMAIL_CHANGE_NOTIFY_BODY to customize the notice sent to the old one.
Admin-facing features: impersonation, RBAC, organizations, invitation-based onboarding, user management, the audit log, and hooks. The service.Auth methods behind these (Impersonate, UsersList, RoleCreate, etc.) enforce no role checks themselves — calling them directly, your application is responsible for checking the caller is allowed (e.g. caller.HasRole("admin")) first. In standalone-service mode, Handler's built-in HTTP routes are different: see below.
By default, Handler's admin/RBAC/org/impersonation HTTP routes (/admin/*, /impersonate, /impersonate/stop, on both the JSON API and form-based transports) require the caller hold the RBAC role Cfg.AdminRole (EZAUTH_ADMIN_ROLE, defaults to "admin") — checked against the RBAC tables, not the legacy User.Roles string field. Unauthenticated requests get 401; authenticated requests lacking the role get 403 (form routes redirect instead, like every other form error).
Bootstrap your first admin with the CLI, then grant the role to others through the API/RBAC methods below:
ezauthapi create-admin -email=admin@example.com -password=<password> # defaults to -role=admin_, _ = auth.Service.RoleCreate(ctx, "admin", "full admin access") // if it doesn't already exist
err := auth.Service.UserRoleGrant(ctx, callerID, userID, "admin") // callerID: the granting admin, recorded on the audit eventTo use a different scheme — RBAC permissions instead of a single role, an org-scoped check, or your own authorization system entirely — pass handler.WithAdminAuthz(middleware) to New(). The middleware runs downstream of auth, so it can check against auth.Service (which satisfies RequirePermission's PermissionChecker interface) directly:
import ezmiddleware "github.com/josuebrunel/ezauth/pkg/handler/middleware"
h := handler.New(auth.Service, "auth", handler.WithAdminAuthz(
ezmiddleware.RequirePermission(auth.Service, "admin:access"),
))Or disable the gate entirely with handler.WithAdminAuthz(nil), restoring the fully-open (any authenticated user) behavior — only do this if you're authorizing this subtree yourself in front of ezauth (e.g. an API gateway); leaving it open otherwise is exactly the privilege-escalation hole this default exists to close.
One exception: the form-based /impersonate and /impersonate/stop routes always enforce Cfg.AdminRole specifically (redirecting on failure, matching FormImpersonate's other error paths) and aren't affected by a custom WithAdminAuthz middleware — only WithAdminAuthz(nil) disables them too. A generic func(http.Handler) http.Handler can't know to redirect instead of writing a JSON body, so this one pair keeps a fixed check.
ezauth supports admin impersonation: an authenticated user can act as another user (e.g. for customer support debugging), then swap back to their own session.
Important
Library mode: the Impersonate method enforces no authorization for who may impersonate — it mints tokens for any target user on behalf of whoever calls it, so check adminUser.HasRole("admin") (or equivalent) yourself before calling it directly.
Standalone-service mode: the /auth/impersonate and /auth/api/impersonate routes are different — by default Handler requires the caller hold the RBAC role Cfg.AdminRole (EZAUTH_ADMIN_ROLE, defaults to "admin"), checked via the RBAC tables (RoleCreate/UserRoleGrant, or the ezauthapi create-admin CLI, to grant it). Pass handler.WithAdminAuthz(middleware) to New() for a different scheme (e.g. RequirePermission), or WithAdminAuthz(nil) to disable the gate and restore the old fully-open behavior if you're authorizing this at a layer in front of ezauth instead. See Admin Authorization below for the full picture — this same gate covers Admin User Management, RBAC, and Organizations too.
An impersonation session's refresh token lives for 1 hour, not the 30 days a normal session's does — TokenRefresh never re-checks the acting admin's current role, only the impersonated target's, so this bounds how long an admin whose role gets revoked mid-impersonation can keep the session going via refresh. Once it lapses, resuming requires a fresh Impersonate call, which re-runs whatever admin-authz check gates it.
// adminUser must already be authenticated; check authorization yourself first.
if !adminUser.HasRole("admin") {
// reject
}
tokenResp, err := auth.Impersonate(ctx, adminUser, targetUserID)
// tokenResp.AccessToken / tokenResp.RefreshToken now authenticate as targetUser,
// with the access token carrying an "act" claim identifying adminUser.
// ... later, end the impersonation session (callerID must match the admin
// who started it, i.e. adminUser.ID):
err = auth.StopImpersonating(ctx, adminUser.ID, tokenResp.RefreshToken)Both a JSON API and form-based (cookie) flow are available, mirroring every other endpoint:
# Start impersonating (JSON API) — requires the admin's own Bearer token.
# refresh_token is the admin's own current refresh token; it's echoed back so the
# client can restore its own session later (the JSON API is stateless).
curl -X POST https://your-host/auth/api/impersonate \
-H "Authorization: Bearer <admin-access-token>" \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"target_user_id": "<target-id>", "refresh_token": "<admin-refresh-token>"}'
# Stop impersonating — pass the impersonation access/refresh token pair.
curl -X POST https://your-host/auth/api/impersonate/stop \
-H "Authorization: Bearer <impersonation-access-token>" \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"refresh_token": "<impersonation-refresh-token>"}'For form-based (cookie) clients, POST /auth/impersonate (with a target_user_id field) swaps the current session cookie over to the target user, stashing the admin's own tokens; POST /auth/impersonate/stop restores them — no re-login required.
- Bearer/JWT mode:
ezauth.GetImpersonatorID(ctx)returns the acting admin's user ID from the access token'sactclaim (requiresAuthMiddleware). - Cookie/session mode:
auth.IsImpersonating(ctx)andauth.GetImpersonator(ctx)report whether the current session is an impersonation session and who the acting admin is.
These two are backed by different storage mechanisms (JWT claims vs. session storage), so a route that can be reached over either transport would otherwise need to branch on which one applies. For that case, use the transport-agnostic pair instead — they check both and return whichever applies:
adminID, ok := auth.CurrentImpersonatorID(ctx) // (string, bool)
admin, err := auth.CurrentImpersonator(ctx) // (*models.User, error)Safe to call regardless of transport: the session-manager middleware always runs first (see SessionMiddleware), even on Bearer-only routes, so the cookie-mode check never panics for lack of loaded session data — it just finds nothing and falls through to the JWT check.
Warning
SQLite deployments only, upgrading from an earlier version: ezauth now enables SQLite's foreign_keys pragma (it's off by default per-connection in SQLite, unlike postgres/mysql). This is what makes the cascading deletes below actually work — but it also means every ON DELETE CASCADE/SET NULL in the schema, not just the new RBAC/organization tables, now really fires: deleting a user really cascades to their tokens/webauthn credentials, etc. (audit logs are the one exception — see Audit Log), where before this fix that cascade was a silent no-op on SQLite specifically. If your SQLite database has accumulated rows that would now get cascade-deleted (or, less likely, orphaned rows that were only surviving because cascades weren't enforced), audit your data before deploying this version. Postgres and MySQL always enforced foreign keys and are unaffected.
ezauth also has real RBAC: roles/permissions tables (many-to-many, via role_permissions/user_roles join tables) plus RequireRole/RequirePermission middleware that enforce against them. This is a fully separate, additive system from the legacy comma-separated User.Roles field and its HasRole/AddRole/RemoveRole/etc. helpers — those keep working exactly as before, but RequireRole/RequirePermission consult the RBAC tables, not that field. Use whichever fits: the string field for a quick, ungoverned tag on a user; the tables when you need actual enforcement, an audit trail of grants/revokes, or permissions distinct from roles.
// One-time setup: define roles/permissions and wire them together.
role, _ := auth.RoleCreate(ctx, "editor", "can edit content")
perm, _ := auth.PermissionCreate(ctx, "posts:write", "write posts")
_ = auth.RolePermissionGrant(ctx, "editor", "posts:write")
// Grant/revoke a role on a user — idempotent, and records an
// AuditEventRoleGranted/AuditEventRoleRevoked audit event (see Audit Log)
// with adminUser.ID as the actor.
_ = auth.UserRoleGrant(ctx, adminUser.ID, user.ID, "editor")
_ = auth.UserRoleRevoke(ctx, adminUser.ID, user.ID, "editor")
// Check directly, or gate a route with the middleware.
has, _ := auth.UserHasRole(ctx, user.ID, "editor")
has, _ = auth.UserHasPermission(ctx, user.ID, "posts:write") // resolved transitively through the user's roles
router.Handle("/admin/posts", auth.RequireRole("editor")(postsHandler))
router.Handle("/admin/posts", auth.RequirePermission("posts:write")(postsHandler))RequireRole/RequirePermission read the authenticated user ID from request context (set by AuthMiddleware or LoadUserMiddleware/SessionMiddleware), so they must run downstream of one of those; a missing user returns 401, a missing role/permission returns 403. Deleting a role or permission cascades: matching user_roles/role_permissions assignment rows are removed automatically.
Admin-style, like Admin User Management — ezauth adds no role check of its own; gate these routes behind your own admin authorization (e.g. RequireRole("admin") in front of them).
# All require the caller's own Bearer token; ezauth adds no role check.
curl -X POST https://your-host/auth/api/admin/roles -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"name": "editor", "description": "can edit content"}'
curl https://your-host/auth/api/admin/roles -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/admin/roles/<role-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/admin/permissions -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"name": "posts:write", "description": "write posts"}'
curl https://your-host/auth/api/admin/permissions -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/admin/permissions/<permission-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/admin/users/<user-id>/roles -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"role_name": "editor"}'
curl https://your-host/auth/api/admin/users/<user-id>/roles -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/admin/users/<user-id>/roles/editor -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/admin/roles/editor/permissions -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"permission_name": "posts:write"}'
curl -X DELETE https://your-host/auth/api/admin/roles/editor/permissions/posts:write -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"Each of the above has a form-based (cookie) equivalent at the same path minus the /api segment (e.g. POST /auth/admin/roles, DELETE /auth/admin/users/{id}/roles/{role_name}). UserHasRole/UserHasPermission stay Go-only — they're boolean checks your own authz code calls, not something you'd hit over HTTP.
Lightweight multi-tenancy: organizations/teams, with each member holding one role per organization — drawn from the same RBAC role catalog RequireRole checks against (a role is just an ezauth_roles row; org membership is ezauth_org_members, mapping (org, user) → role), except Cfg.AdminRole itself: OrgMemberAdd always refuses to grant it, so org-scoped membership can't be used to escalate to application-wide admin. Kept deliberately minimal — no settings/billing/invitations — a consuming app that needs more can extend via its own table FK'd to ezauth_organizations. Org membership rows cascade-delete like everything else in the schema — see the SQLite foreign-key note at the top of Roles & Permissions (RBAC) if you're upgrading an existing SQLite deployment.
org, err := auth.OrganizationCreate(ctx, "Acme Inc")
// OrgMemberAdd upserts: calling it again for the same (org, user) updates the role.
err = auth.OrgMemberAdd(ctx, org.ID, user.ID, "editor")
err = auth.OrgMemberRemove(ctx, org.ID, user.ID)
members, err := auth.OrgMembersList(ctx, org.ID) // []*models.OrgMember, RoleName joined in
orgs, err := auth.UserOrganizationsList(ctx, user.ID) // organizations this user belongs toResolving the "current org" for a request mirrors how LoadUserMiddleware/GetSessionUser resolve the current user — ezauth doesn't presume how an org is identified (URL param, subdomain, header, etc.), so you supply an OrgLoader:
r.Use(auth.OrgLoaderMiddleware(func(ctx context.Context) (*models.Organization, error) {
orgID := chi.URLParam(r, "orgID") // or a subdomain, header, etc. — your choice
return auth.OrganizationGetByID(ctx, orgID)
}))// *models.Organization, set by OrgLoaderMiddleware. This is the package-level
// helper — ezauth.GetSessionOrg(ctx) — not a method on the *EzAuth instance.
org, err := ezauth.GetSessionOrg(ctx)The org service methods (OrganizationGetByID, OrgMemberAdd, OrganizationDelete, ...) perform no membership check themselves — same as every other RBAC-gated method in this package, they rely on the HTTP-gate layer for authorization. Compose OrgLoaderMiddleware with RequireOrgMembership (any role) or RequireOrgRole (an exact role) if a route needs to scope access to the current org's actual members, instead of (or in addition to) a blanket admin gate.
Admin-style, like Admin User Management — ezauth adds no role check of its own; gate these routes behind your own admin authorization.
# All require the caller's own Bearer token; ezauth adds no role check.
curl -X POST https://your-host/auth/api/admin/organizations -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"name": "Acme Inc"}'
curl "https://your-host/auth/api/admin/organizations?limit=50&offset=0" -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl https://your-host/auth/api/admin/organizations/<org-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/admin/organizations/<org-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
# Add/update a member — same call re-adds with a new role:
curl -X POST https://your-host/auth/api/admin/organizations/<org-id>/members -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"user_id": "<user-id>", "role_name": "editor"}'
curl https://your-host/auth/api/admin/organizations/<org-id>/members -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/admin/organizations/<org-id>/members/<user-id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl https://your-host/auth/api/admin/users/<user-id>/organizations -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"Each of the above has a form-based (cookie) equivalent at the same path minus the /api segment (e.g. POST /auth/admin/organizations, DELETE /auth/admin/organizations/{id}/members/{user_id}); OrganizationsList's limit/offset query params work the same way there too.
Any authenticated user can invite someone by email; the invitee gets a link that pre-fills registration with their email pre-verified and, optionally, a pre-assigned role. ezauth enforces no authorization on who may invite — check that yourself (e.g. auth.Service.UserHasRole(ctx, inviter.ID, "admin")) before calling it if you want to restrict invitation-sending itself. It does enforce authorization on what roles an invitation can grant, though: InvitationCreate rejects (service.ErrCannotGrantRole) any requested role the inviter doesn't already hold via the RBAC roles/permissions tables (UserHasRole — the same source of truth RequireRole checks, not the legacy User.Roles field), so a plain user can never self-invite with roles:"admin" and escalate — an invitation can't grant a role its creator doesn't have. InvitationAccept grants the invited roles the same way (UserRoleGrant, attributed to the original inviter), rather than writing the legacy field. Data remains fully opaque to ezauth, carried through to the created account unchecked, so a multi-tenancy layer built on top can put an org ID there without ezauth needing to know what it means.
// inviter must already be authenticated; check authorization yourself first.
info, err := auth.Service.InvitationCreate(ctx, inviter, service.RequestInvitation{
Email: "newperson@example.com",
Roles: "member",
Data: map[string]any{"org_id": "org-123"},
})
// Later, the invitee visits the emailed link and submits a password:
user, tokens, err := auth.Service.InvitationAccept(ctx, service.RequestInvitationAccept{
Token: tokenFromLink,
Password: "their-chosen-password",
})
// user.EmailVerified is already true, and the "member" role has already
// been granted via RBAC -- auth.Service.UserHasRole(ctx, user.ID, "member").
// Managing invitations:
invitations, err := auth.Service.Invitations(ctx, inviter.ID)
err = auth.Service.InvitationRevoke(ctx, inviter, invitations[0].ID)# Create an invitation (requires the inviter's own Bearer token):
curl -X POST https://your-host/auth/api/invitations -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"email": "newperson@example.com", "roles": "member"}'
# The invitee previews their invitation (no auth required):
curl "https://your-host/auth/api/invitations/preview?token=<token>" -H "X-API-Key: your-api-key"
# ...then accepts it:
curl -X POST https://your-host/auth/api/invitations/accept -H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" -d '{"token": "<token>", "password": "their-chosen-password"}'
# Manage invitations (requires the inviter's own Bearer token):
curl https://your-host/auth/api/invitations -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X DELETE https://your-host/auth/api/invitations/<id> -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"For form-based (cookie) clients, POST /auth/invitations (fields email, roles), GET /auth/invitations, and DELETE /auth/invitations/{id} work the same way against the logged-in session user; GET /auth/invitation/accept?token=... (the link emailed to the invitee) redirects to Pages.InvitationAccept with the token preserved as a query param, and POST /auth/invitation/accept (fields token, password, password_confirm, plus optional username/first_name/last_name) completes registration and sets auth cookies.
Set EZAUTH_INVITATION_TTL (default 168h/7 days) to control how long an invitation stays valid, and EZAUTH_EMAIL_INVITATION_SUBJECT/EZAUTH_EMAIL_INVITATION_BODY to customize the invitation email.
Beyond impersonation, ezauth exposes admin-facing endpoints to list/search users, suspend/reactivate an account, and view a user's auth history. The service.Auth methods below enforce no authorization themselves (same stance as Impersonate) — check that yourself before calling them directly. In standalone-service mode, Handler's HTTP routes are gated by default instead — see Admin Authorization.
result, err := auth.Service.UsersList(ctx, service.ListUsersOptions{
Search: "alice", // matches email or username
Status: models.UserStatusSuspended, // "active" | "locked" | "suspended"
Limit: 20,
Offset: 0,
})
// result.Users (PasswordHash stripped), result.HasMore
user, err := auth.Service.UserSuspend(ctx, targetUserID)
user, err = auth.Service.UserReactivate(ctx, targetUserID)
history, err := auth.Service.UserAuthHistory(ctx, targetUserID, 50)
// []service.AuthHistoryEntry — token_type/created_at/expires_at/revoked, newest firstUserStatusActive/UserStatusLocked/UserStatusSuspended distinguish three states derived from the existing IsActive/lockout columns: active (IsActive true), locked (temporarily and automatically expiring — see Account Lockout), and suspended (IsActive false with no expiry, i.e. this feature's UserSuspend). UserAuthHistory is a lightweight proxy built from the same Tokens table every other feature already writes to — for a real persisted audit trail of named security events, see Audit Log.
# All require the caller's own Bearer token; ezauth adds no role check.
curl "https://your-host/auth/api/admin/users?search=alice&status=suspended&limit=20" \
-H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/admin/users/<id>/suspend -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl -X POST https://your-host/auth/api/admin/users/<id>/reactivate -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"
curl "https://your-host/auth/api/admin/users/<id>/history" -H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"GET /auth/api/admin/users accepts search, status (active/locked/suspended), created_after/created_before, last_active_after/last_active_before (RFC3339 timestamps), and limit/offset (default 20, max 100). For form-based (cookie) clients, the same query params work against /auth/admin/users, /auth/admin/users/{id}/suspend, /auth/admin/users/{id}/reactivate, and /auth/admin/users/{id}/history, authenticated via the logged-in session user instead of a Bearer token.
ezauth persists a row to an audit log for security-relevant events — login success/failure, password reset, impersonation start/stop, account lockout, MFA enable/disable, user create/delete — automatically, via a built-in hook that wraps whatever Hook you register (see Hooks) so it keeps working whether or not you set your own. Enabled by default; disable with EZAUTH_AUDIT_LOG_ENABLED=false.
result, err := auth.Service.AuditLogs(ctx, targetUserID, service.ListAuditLogsOptions{
EventType: models.AuditEventLoginFailed, // optional, e.g. "login.failed"
Since: &since, // optional, RFC3339
Limit: 50,
})
// result.Events ([]*models.AuditLog: user_id, event_type, metadata, created_at), result.HasMoreAuditLog.UserID is *string, not string: unlike every other user-owned table, deleting a user does not cascade-delete their audit-log rows — the foreign key is ON DELETE SET NULL, so the row survives with UserID set to nil. The event still happened and is still evidence, even once the account itself is gone; erasing it at exactly the moment an account is deleted would defeat the point of an audit trail.
Event types are the models.AuditEvent* constants (e.g. AuditEventLoginSucceeded, AuditEventAccountLocked, AuditEventRoleGranted/AuditEventRoleRevoked). Login failures and account lockouts each get their own hook method — AfterLoginFailed and AfterAccountLocked (they're part of the Hook interface, so embed DefaultHook and override only what you need to react to them). "Email verification" isn't recorded yet since ezauth doesn't have an email-verification-confirm flow.
curl "https://your-host/auth/api/admin/users/<id>/audit-logs?event_type=login.failed&limit=50" \
-H "Authorization: Bearer <access-token>" -H "X-API-Key: your-api-key"Accepts event_type, since/until (RFC3339 timestamps), and limit/offset (default 50, max 200) — same auth stance as the rest of Admin User Management (no role check; gate this yourself). The cookie-session equivalent is GET /auth/admin/users/{id}/audit-logs.
ezauth provides a hook system that lets you intercept auth lifecycle events. This is useful for:
- Validating input before user creation (e.g., checking a banned domains table)
- Sending welcome emails or audit logs after registration
- Notifying admins of new user registrations
- Audit logging of sign-ins, sign-outs, and account deletion
Embed service.DefaultHook and override only the methods you need:
type MyHook struct {
service.DefaultHook
db *sql.DB
log *slog.Logger
}
// BeforeUserCreated runs before a new user is persisted.
// Return an error to abort the operation.
func (h MyHook) BeforeUserCreated(ctx context.Context, u *models.User) error {
var banned bool
err := h.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM banned_domains WHERE domain = ?)",
emailDomain(u.Email),
).Scan(&banned)
if err != nil {
return err
}
if banned {
return errors.New("email domain is not allowed")
}
return nil
}
// AfterUserCreated runs after a user has been successfully persisted.
func (h MyHook) AfterUserCreated(ctx context.Context, u *models.User) error {
// Audit log
_, err := h.db.ExecContext(ctx,
"INSERT INTO audit_log (event, user_id, ts) VALUES (?, ?, ?)",
"user.created", u.ID, time.Now(),
)
if err != nil {
return err
}
// Send welcome email (async — no extra framework needed)
go h.sendWelcomeEmail(u.Email)
h.log.InfoContext(ctx, "new user registered", "id", u.ID, "email", u.Email)
return nil
}
// AfterUserSignedIn can be used for login notifications or audit trails.
func (h MyHook) AfterUserSignedIn(ctx context.Context, u *models.User) error {
h.log.InfoContext(ctx, "user signed in", "id", u.ID, "email", u.Email)
return nil
}| Hook | Timing | Abortable |
|---|---|---|
BeforeUserCreated |
Before creating a new user | Yes (return error) |
AfterUserCreated |
After a new user is persisted | No (errors are logged) |
BeforeUserUpdated |
Before updating a user | Yes (return error) |
AfterUserUpdated |
After a user is updated | No (errors are logged) |
BeforeUserDeleted |
Before deleting a user | Yes (return error) |
AfterUserDeleted |
After a user is deleted | No (errors are logged) |
AfterUserSignedIn |
After a successful sign-in | No (errors are logged) |
AfterUserSignedOut |
After a successful sign-out | No (errors are logged) |
AfterPasswordResetRequested |
After a password reset is requested | No (errors are logged) |
AfterPasswordResetConfirmed |
After a password reset is confirmed | No (errors are logged) |
AfterOAuth2SignedIn |
After an existing user signs in via OAuth2 | No (errors are logged) |
AfterOAuth2Created |
After a new user is created via OAuth2 | No (errors are logged) |
AfterImpersonationStarted |
After an admin begins impersonating a user | No (errors are logged) |
AfterImpersonationEnded |
After an impersonation session ends | No (errors are logged) |
AfterMFAEnabled |
After a user enables TOTP MFA | No (errors are logged) |
AfterMFADisabled |
After a user disables TOTP MFA | No (errors are logged) |
AfterLoginFailed |
After a failed login attempt (known user) | No (errors are logged) |
AfterAccountLocked |
After an account is locked out | No (errors are logged) |
Every After*/outcome hook above (except AfterUserUpdated) also feeds the built-in Audit Log — your own hook and audit persistence both run, regardless of which Hook you register. The Before* hooks and AfterUserUpdated only run your code; they don't persist an audit row on their own (role grants/revokes are audited separately by UserRoleGrant/UserRoleRevoke).
auth.SetHook(MyHook{
db: sqlDB,
log: slog.Default(),
})It's safe to call SetHook at any point — including after the server is running. Use auth.Hook() to read back the currently registered Hook.
Every HTTP endpoint, form field, and helper, in one place.
These endpoints accept application/x-www-form-urlencoded, set secure cookies, and redirect. (GET /auth/register, GET /auth/login, and GET /auth/csrf are lightweight exceptions: the first two redirect to the configured page URLs, and /auth/csrf returns a JSON CSRF token for legacy clients.)
| Method | Endpoint | Description |
|---|---|---|
| GET | /auth/register |
Redirect to the configured Register Page (EZAUTH_REGISTER_PAGE_URL) |
| POST | /auth/register |
Register a new user |
| GET | /auth/login |
Redirect to the configured Login Page (EZAUTH_LOGIN_PAGE_URL) |
| POST | /auth/login |
Login and set cookies |
| GET | /auth/csrf |
Return a JSON CSRF token ({"csrf_token": "..."}) for legacy clients |
| POST | /auth/logout |
Clear cookies and logout |
| POST | /auth/impersonate |
Start impersonating a user (see Impersonation) |
| POST | /auth/impersonate/stop |
Stop impersonating and restore the admin's session |
| POST | /auth/password-reset/request |
Request password reset link |
| POST | /auth/password-reset/confirm |
Confirm password reset |
| POST | /auth/passwordless/request |
Request magic link |
| GET | /auth/passwordless/login |
Login via magic link (also accepts POST with token in the body -- safer, since a query-string token lands in access logs/history/Referer headers; both responses set Referrer-Policy: no-referrer) |
| GET | /auth/oauth2/{provider}/login |
Login via OAuth2 provider |
| GET | /auth/oauth2/{provider}/callback |
OAuth2 provider callback. URL: {base_url}/auth/oauth2/{provider}/callback |
| POST | /auth/sms-otp/request |
Request an SMS one-time login code (see SMS OTP) |
| POST | /auth/sms-otp/verify |
Login via SMS one-time code |
| GET | /auth/mfa/verify |
Redirects to Pages.MFAVerify (see Multi-Factor Authentication) |
| POST | /auth/mfa/login/verify |
Complete a step-up login using the session-stashed pre-auth token |
| POST | /auth/mfa/enroll |
Begin TOTP enrollment for the logged-in session user |
| POST | /auth/mfa/confirm |
Confirm enrollment and enable MFA |
| POST | /auth/mfa/disable |
Disable MFA |
| GET | /auth/trusted-devices |
List the logged-in session user's trusted devices (see Remember This Device) |
| DELETE | /auth/trusted-devices/{id} |
Revoke one of the logged-in session user's trusted devices |
| GET | /auth/sessions |
List the logged-in session user's active sessions (see Sessions) |
| DELETE | /auth/sessions/{id} |
Revoke one session by its ID ("log out one device") |
| DELETE | /auth/sessions |
Revoke all sessions; pass ?except={id} to keep one ("log out other devices") |
| POST | /auth/api-keys |
Create an API key for the logged-in session user (see Scoped API Keys) |
| GET | /auth/api-keys |
List the logged-in session user's API keys |
| DELETE | /auth/api-keys/{id} |
Revoke one of the logged-in session user's API keys |
| POST | /auth/webauthn/login/begin |
Begin a discoverable WebAuthn login ceremony (see WebAuthn / Passkeys) |
| POST | /auth/webauthn/login/finish |
Complete a WebAuthn login and set auth cookies |
| POST | /auth/webauthn/register/begin |
Begin passkey registration for the logged-in session user |
| POST | /auth/webauthn/register/finish |
Complete passkey registration |
| GET | /auth/webauthn/credentials |
List the logged-in session user's passkeys |
| DELETE | /auth/webauthn/credentials/{id} |
Delete one of the logged-in session user's passkeys |
| GET | /auth/invitation/accept |
Redirects to Pages.InvitationAccept with the token preserved (see Invitation-Based Onboarding) |
| POST | /auth/invitation/accept |
Complete registration from an invitation and set auth cookies |
| POST | /auth/invitations |
Create an invitation as the logged-in session user |
| GET | /auth/invitations |
List invitations issued by the logged-in session user |
| DELETE | /auth/invitations/{id} |
Revoke one of the logged-in session user's invitations |
| GET | /auth/invitations/preview |
Preview a pending invitation by its token (no auth required) |
| POST | /auth/email-change/request |
Request an email change as the logged-in session user (see Guarded Email Change) |
| GET | /auth/email-change/confirm |
Confirm an email change, clear the session, and redirect to Pages.Login (also accepts POST with token in the body -- see the passwordless-login row above for why) |
| GET | /auth/admin/users |
List/search/filter users as the logged-in session user (see Admin User Management) |
| POST | /auth/admin/users/{id}/suspend |
Suspend a user's account |
| POST | /auth/admin/users/{id}/reactivate |
Reactivate a user's account |
| GET | /auth/admin/users/{id}/history |
View a user's auth history |
| GET | /auth/admin/users/{id}/audit-logs |
View a user's persisted audit log (see Audit Log) |
RBAC and organization endpoints for the logged-in session user (same paths as the JSON API below minus the /api segment):
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/admin/roles |
Create a role (see Roles & Permissions (RBAC)) |
| GET | /auth/admin/roles |
List roles |
| DELETE | /auth/admin/roles/{id} |
Delete a role (assignments cascade) |
| POST | /auth/admin/permissions |
Create a permission |
| GET | /auth/admin/permissions |
List permissions |
| DELETE | /auth/admin/permissions/{id} |
Delete a permission (assignments cascade) |
| POST | /auth/admin/users/{id}/roles |
Grant a role to a user (field role_name) |
| GET | /auth/admin/users/{id}/roles |
List a user's roles |
| DELETE | /auth/admin/users/{id}/roles/{role_name} |
Revoke a role from a user |
| POST | /auth/admin/roles/{name}/permissions |
Grant a permission to a role (field permission_name) |
| DELETE | /auth/admin/roles/{name}/permissions/{permission_name} |
Revoke a permission from a role |
| POST | /auth/admin/organizations |
Create an organization (see Organizations) |
| GET | /auth/admin/organizations |
List organizations (limit/offset) |
| GET | /auth/admin/organizations/{id} |
Fetch one organization |
| DELETE | /auth/admin/organizations/{id} |
Delete an organization (membership rows cascade) |
| POST | /auth/admin/organizations/{id}/members |
Add/update a member (fields user_id, role_name; upserts) |
| GET | /auth/admin/organizations/{id}/members |
List an organization's members (role name joined in) |
| DELETE | /auth/admin/organizations/{id}/members/{user_id} |
Remove a member from an organization |
| GET | /auth/admin/users/{id}/organizations |
List the organizations a user belongs to |
| Endpoint | Required Fields | Optional Fields |
|---|---|---|
/auth/register |
email, password, password_confirm |
username, first_name, last_name, locale, timezone, phone, avatar_url, nickname, meta_* |
/auth/login |
email, password |
|
/auth/impersonate |
target_user_id |
|
/auth/password-reset/request |
email |
|
/auth/password-reset/confirm |
token, password |
|
/auth/passwordless/request |
email |
|
/auth/passwordless/login |
token (query param on GET, form field on POST) |
|
/auth/sms-otp/request |
phone |
|
/auth/sms-otp/verify |
phone, code |
|
/auth/mfa/login/verify |
code |
|
/auth/mfa/confirm |
code |
|
/auth/mfa/disable |
code |
|
/auth/mfa/login/verify (remember) |
code |
remember_device (any non-empty value) |
/auth/invitations |
email |
roles |
/auth/invitation/accept |
token, password, password_confirm |
username, first_name, last_name, locale, timezone |
/auth/email-change/request |
current_password, new_email |
Note
Passwords must be between 8 and 128 characters long. The /auth/webauthn/* endpoints are not listed here: they take the browser's raw navigator.credentials JSON response as the request body (plus session_key/name query params), not form-encoded fields — see WebAuthn / Passkeys.
These endpoints accept application/json and return JSON responses. Endpoints marked (Protected) require an Authorization: Bearer <access_token> header; everything else under /auth/api requires only the master X-API-Key.
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/api/register |
Register a new user |
| POST | /auth/api/login |
Login and receive tokens |
| POST | /auth/api/token/refresh |
Refresh access token |
| POST | /auth/api/password-reset/request |
Request password reset link |
| POST | /auth/api/password-reset/confirm |
Confirm password reset |
| POST | /auth/api/passwordless/request |
Request magic link |
| GET | /auth/api/passwordless/login |
Login via magic link (also accepts POST with {"token": "..."} -- safer, since a query-string token lands in access logs/history/Referer headers; both responses set Referrer-Policy: no-referrer) |
| POST | /auth/api/sms-otp/request |
Request an SMS one-time login code (see SMS OTP) |
| POST | /auth/api/sms-otp/verify |
Login via SMS one-time code |
| GET | /auth/api/userinfo |
Get current user info (Protected) |
| POST | /auth/api/logout |
Revoke refresh token (Protected) |
| POST | /auth/api/impersonate |
Start impersonating a user (Protected, see Impersonation) |
| POST | /auth/api/impersonate/stop |
Stop impersonating (Protected) |
| DELETE | /auth/api/user |
Delete account (Protected) |
| POST | /auth/api/mfa/login/verify |
Complete a step-up login (see Multi-Factor Authentication) |
| POST | /auth/api/mfa/enroll |
Begin TOTP enrollment (Protected) |
| POST | /auth/api/mfa/confirm |
Confirm enrollment and enable MFA (Protected) |
| POST | /auth/api/mfa/disable |
Disable MFA (Protected) |
| GET | /auth/api/trusted-devices |
List the authenticated user's trusted devices (Protected, see Remember This Device) |
| DELETE | /auth/api/trusted-devices/{id} |
Revoke one of the authenticated user's trusted devices (Protected) |
| GET | /auth/api/sessions |
List the authenticated user's active sessions (Protected, see Sessions) |
| DELETE | /auth/api/sessions/{id} |
Revoke one session by its ID (Protected) |
| DELETE | /auth/api/sessions |
Revoke all sessions; pass ?except={id} to keep one (Protected) |
| POST | /auth/api/api-keys |
Create an API key for the authenticated user (Protected, see Scoped API Keys) |
| GET | /auth/api/api-keys |
List the authenticated user's API keys (Protected) |
| DELETE | /auth/api/api-keys/{id} |
Revoke one of the authenticated user's API keys (Protected) |
| POST | /auth/api/webauthn/login/begin |
Begin a discoverable WebAuthn login ceremony (see WebAuthn / Passkeys) |
| POST | /auth/api/webauthn/login/finish |
Complete a WebAuthn login and receive tokens |
| POST | /auth/api/webauthn/register/begin |
Begin passkey registration (Protected) |
| POST | /auth/api/webauthn/register/finish |
Complete passkey registration (Protected) |
| GET | /auth/api/webauthn/credentials |
List the authenticated user's passkeys (Protected) |
| DELETE | /auth/api/webauthn/credentials/{id} |
Delete one of the authenticated user's passkeys (Protected) |
| POST | /auth/api/invitations |
Create an invitation (Protected, see Invitation-Based Onboarding) |
| GET | /auth/api/invitations |
List invitations issued by the authenticated user (Protected) |
| DELETE | /auth/api/invitations/{id} |
Revoke one of the authenticated user's invitations (Protected) |
| GET | /auth/api/invitations/preview |
Preview a pending invitation by its token |
| POST | /auth/api/invitations/accept |
Complete registration from an invitation and receive tokens |
| POST | /auth/api/email-change/request |
Request an email change (Protected, see Guarded Email Change) |
| GET | /auth/api/email-change/confirm |
Confirm an email change (also accepts POST with {"token": "..."} -- see the passwordless-login row above for why) |
| GET | /auth/api/admin/users |
List/search/filter users (Protected, see Admin User Management) |
| POST | /auth/api/admin/users/{id}/suspend |
Suspend a user's account (Protected) |
| POST | /auth/api/admin/users/{id}/reactivate |
Reactivate a user's account (Protected) |
| GET | /auth/api/admin/users/{id}/history |
View a user's auth history (Protected) |
| GET | /auth/api/admin/users/{id}/audit-logs |
View a user's persisted audit log (Protected, see Audit Log) |
RBAC and organization endpoints (all Protected; see Roles & Permissions (RBAC) and Organizations):
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/api/admin/roles |
Create a role |
| GET | /auth/api/admin/roles |
List roles |
| DELETE | /auth/api/admin/roles/{id} |
Delete a role (assignments cascade) |
| POST | /auth/api/admin/permissions |
Create a permission |
| GET | /auth/api/admin/permissions |
List permissions |
| DELETE | /auth/api/admin/permissions/{id} |
Delete a permission (assignments cascade) |
| POST | /auth/api/admin/users/{id}/roles |
Grant a role to a user (body role_name) |
| GET | /auth/api/admin/users/{id}/roles |
List a user's roles |
| DELETE | /auth/api/admin/users/{id}/roles/{role_name} |
Revoke a role from a user |
| POST | /auth/api/admin/roles/{name}/permissions |
Grant a permission to a role (body permission_name) |
| DELETE | /auth/api/admin/roles/{name}/permissions/{permission_name} |
Revoke a permission from a role |
| POST | /auth/api/admin/organizations |
Create an organization (body name) |
| GET | /auth/api/admin/organizations |
List organizations (limit/offset) |
| GET | /auth/api/admin/organizations/{id} |
Fetch one organization |
| DELETE | /auth/api/admin/organizations/{id} |
Delete an organization (membership rows cascade) |
| POST | /auth/api/admin/organizations/{id}/members |
Add/update a member (body user_id, role_name; upserts) |
| GET | /auth/api/admin/organizations/{id}/members |
List members (role name joined in) |
| DELETE | /auth/api/admin/organizations/{id}/members/{user_id} |
Remove a member |
| GET | /auth/api/admin/users/{id}/organizations |
List the organizations a user belongs to |
Mounted outside the /auth prefix on the ezauth handler's router root:
| Method | Endpoint | Description |
|---|---|---|
| GET | /ping |
Health/liveness check |
| GET | /swagger/* |
Swagger UI (see Swagger Documentation) |
| GET | /.well-known/jwks.json |
JWKS endpoint for asymmetric JWT signing (see Asymmetric JWT Signing (JWKS)) |
To generate the Swagger documentation, run:
make swaggerThe Swagger UI is available at /swagger/index.html.
Warning
/swagger/* is served with no authentication by default, exposing the full API surface/schema to anyone who can reach the service. Gate or disable it with handler.WithSwaggerAuth:
// Gate it behind your own middleware:
h := handler.New(auth.Service, "auth", handler.WithSwaggerAuth(yourAuthMiddleware))
// Or remove the route entirely:
h := handler.New(auth.Service, "auth", handler.WithSwaggerAuth(nil))Check out the _example directory for ready-to-use examples:
go-server: A complete, plug-and-play example showing how to integrateezauthwith a Go web server.javascript-client: An example JavaScript client interacting with theezauthAPI.
For a fuller, runnable showcase — authentication, profile updates, scoped API keys, sessions, impersonation, and admin/RBAC management, wired into Echo v5 + Templ + HTMX + SQLite — see ezauth-example.