Skip to content

Repository files navigation

ezauth

Tests Documentation Go Reference

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.

Contents

Features

  • 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.Roles field
  • 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

Quickstart

Get started in two ways: embed ezauth directly in your Go application as a library, or run it as a standalone authentication service.

Install

go get github.com/josuebrunel/ezauth

As a Library

Embed 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)
}

Standalone Service

You can run ezauth as a separate service that handles authentication for your microservices, instead of embedding it as a library.

  1. 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"
  2. 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-admin before you need any admin/RBAC/org/impersonation route (/auth/api/admin/*, /auth/api/impersonate, ...) — by default Handler denies all of them (401/403) until at least one user holds the admin RBAC role. See Admin Authorization.

Sessions, Middleware and Helpers

Core building blocks for the library API: cookie-based sessions, route protection middlewares, and helper functions for handlers and templates.

Session Management (Cookies)

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 implementation

The 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"]
    // ...
}

Retrieving the Authenticated User

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)
})

Handling Errors and Success Messages

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)
})

CSRF Protection

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.

Middlewares

ezauth provides several "plug and play" middlewares to protect your routes and manage user sessions. These are available directly on the EzAuth instance.

auth.SessionMiddleware

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.

auth.LoginRequiredMiddleware

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.

auth.LoadUserMiddleware

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.

auth.AuthMiddleware

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.

Helper Functions

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())
}

User Model Helpers

The User struct includes helper methods for common operations:

Role Helpers
  • 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.
Display Helpers
  • FullName() string: Returns the user's first and last name combined.
  • DisplayName() string: Returns the best available name (FullName > Username > email local-part).
Provider Helpers
  • 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.
Security Helpers
  • Sanitize(): Clears sensitive fields (e.g., PasswordHash) before serialization.
Metadata Helpers
  • GetMeta[T any](user, key) (T, bool): Retrieves a value from UserMetadata with type casting.
  • SetMeta(key, value): Sets a value in UserMetadata.
  • GetAppMeta[T any](user, key) (T, bool): Retrieves a value from AppMetadata.
  • SetAppMeta(key, value): Sets a value in AppMetadata.

Sign-in Methods

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.

Custom OAuth2 / OIDC Providers

You can register arbitrary custom providers dynamically via environment variables (Standalone-service mode) or in Go code (Library mode) as shown below.

Standalone-service mode (via Env Vars)

  1. Add your provider's name to EZAUTH_OAUTH2_PROVIDERS (comma-separated).
  2. 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 (default id), EMAIL_FIELD (default email)).

Library Mode (Go Code)

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)

SMS OTP

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",
})

Standalone-service Mode

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.

WebAuthn / Passkeys

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.

Registration (Library Mode)

// 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")

Login (Library Mode)

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)

Managing Credentials

creds, err := auth.WebauthnCredentials(ctx, user.ID)
err = auth.WebauthnDeleteCredential(ctx, user, credentialRecordID)

Standalone-service Mode

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.

Account Security

Second-factor and hardening features: MFA, session revocation, lockout, guarded email changes, and asymmetric JWT signing.

Token Storage

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.

Multi-Factor Authentication (TOTP)

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.

Enrollment

// 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.

Step-up Login

// 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.
}

Disabling

err := auth.MFADisable(ctx, user, code) // accepts a TOTP or recovery code

Standalone-service Mode

# 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.

Remember This Device (Trusted Devices)

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).

Sessions

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 everywhere

For 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[...].

Refresh Token Reuse Detection

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.

Account Lockout

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).

Asymmetric JWT Signing (JWKS)

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 mode

Claims: 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.

Scoped API Keys

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's
r.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.

Standalone-service Mode

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).

Guarded Email Change

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.

Standalone-service Mode

# 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 and Operations

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.

Admin Authorization

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 event

To 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.

Impersonation

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.

Library Mode

// 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)

Standalone-service Mode

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.

Detecting an Impersonation Session

  • Bearer/JWT mode: ezauth.GetImpersonatorID(ctx) returns the acting admin's user ID from the access token's act claim (requires AuthMiddleware).
  • Cookie/session mode: auth.IsImpersonating(ctx) and auth.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.

Roles & Permissions (RBAC)

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.

Standalone-service Mode

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.

Organizations

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 to

Resolving 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.

Standalone-service Mode

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.

Invitation-Based Onboarding

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)

Standalone-service Mode

# 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.

Admin User Management

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 first

UserStatusActive/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.

Standalone-service Mode

# 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.

Audit Log

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.HasMore

AuditLog.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.

Standalone-service Mode

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.

Hooks

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

Defining a Hook

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
}

Available Hooks

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).

Registering the Hook

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.

API Reference

Every HTTP endpoint, form field, and helper, in one place.

API Endpoints

Form-based Handlers (Cookies & Redirects)

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
Form Field Reference
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.

API Handlers (JSON)

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
Un-prefixed Routes

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))

Appendix

Swagger Documentation

To generate the Swagger documentation, run:

make swagger

The 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))

Examples

Check out the _example directory for ready-to-use examples:

  • go-server: A complete, plug-and-play example showing how to integrate ezauth with a Go web server.
  • javascript-client: An example JavaScript client interacting with the ezauth API.

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.