goravel-authkit uses session-cookie authentication for browsers and optional
opaque personal API tokens for non-browser clients. This document states the
guarantees, the operator responsibilities, and the known limitations.
A guard is an isolated authentication domain. When an app runs several guards
(under authkit.guards), each is separated from the others by:
- a separate user table (resolved per query via GORM
.Table(name)), - a separate session key — Goravel's
auth_<guard>_idplus authkit's per-guard bookkeeping (authkit_<guard>_password_changed_at,…_two_factor_user_id,…_remember_intent), so one shared session cookie carries multiple guards without collision, - a separate remember cookie (
authkit_<guard>_rememberby default), so two guards on one origin don't overwrite each other's persistent login, - a separate API-token table (
<guard>_api_tokensby default), - namespaced rate-limit buckets — each guard includes its name in every IP, account, 2FA-user, and password-user bucket, so attempts never cross guards even when they share one backend.
The user record is loaded from the guard's own table through authkit's
table-aware repository (keyed by the session's auth_<guard>_id); the Goravel
guard is only a per-guard session-id store. Table names come from config
(authkit.guards.<name>.users_table, etc.) — they are developer-controlled, not
user input, and are applied via GORM .Table(), so they are not an injection
vector. The browser session model remains httpOnly and never stores API tokens
in localStorage.
The auto-registered Goravel guard uses a session driver over a shared
authkit_orm provider and never clobbers a guard the host has already
defined (it only fills in an unset guard).
API tokens are off by default. When enabled, Authkit issues opaque
gak_<selector>.<validator> credentials with mandatory expiry:
- Only the selector and SHA-256 validator hash are persisted; plaintext is shown once at creation and cannot be recovered.
- Creation/list/revocation endpoints accept session authentication only, retain CSRF checks, and creation requires the current password plus TOTP when enabled. All management is rejected during impersonation.
- Allowed scopes are configured per guard. Required middleware scopes only restrict a token; they never grant permissions beyond the live user's role and the host application's normal resource authorization.
- Every request loads the live owner. Deleted or disabled owners are denied. Password changes/resets and disabling/deleting a user revoke all tokens by default.
ProtectTokenis stateless.ProtectAnyprefers any supplied Authorization header and never falls back to a session after an invalid token. Browser/session requests still receive CSRF protection.- Schedule
auth:prune-api-tokensdaily to remove tokens expired or revoked more than 30 days ago. Never put bearer tokens in URLs or logs; use TLS.
- Password hashing via the Goravel Hash facade (bcrypt). Hashes are never
serialized (
json:"-") and never logged. - No credential enumeration on login. Unknown-email and wrong-password
return the identical
401 invalid_credentials. The unknown-email path runs a real bcrypt comparison against a valid dummy hash so response timing does not reveal whether an email exists. - Session-fixation protection. The session id is regenerated on login and the cookie re-emitted.
- Multi-session invalidation on password change. Each session stores the
password_changed_atcaptured at login (under the per-guard keyauthkit_<guard>_password_changed_at); theAuthenticatedmiddleware compares it to the live DB value on every request. A password change bumps the DB value, so every other session is rejected (401 session_expired) on its next request. The session that changed the password is re-stamped and stays in. - Atomic multi-dimensional rate-limiting. Login and 2FA must pass both an IP
bucket (default 20/min) and a hash-keyed account/user bucket (default 5/min).
Change-password must pass IP and authenticated-user buckets. Bucket identifiers
are SHA-256 hashed before reaching the store. The default atomic sliding-window
store is process-local; hosts can register a distributed implementation through
authkit.RegisterRateLimitStore. - CSRF origin verification. Every state-changing Authkit endpoint, plus host
routes using
routes.Protect/ProtectRole, fails closed unlessOriginorReferermatches the request host or an exactauthkit.csrf.trusted_originsentry. Requests without either header are accepted only withSec-Fetch-Site: same-origin. Non-browser clients must identify their origin too. /usersis admin-gated by default (fail-closed). The user-management endpoints (read and write) are always mounted behind aRequireRolecheck;authkit.user_management_rolesdefaults to["admin"]. A non-admin gets 403.- New users default to the least-privileged role. A create with no explicit
role is assigned a non-management role (the first configured non-admin role,
else
"user") — it can never silently becomeadmin. There is no DB-levelroledefault. - Parameterized queries. All DB access goes through GORM with bound parameters — no string-built SQL.
- bcrypt cost 12.
package:installsetsbcrypt.rounds: 12inconfig/hashing.go; keep it (required for hashing to work and to stay compatible with existing$2a$12$hashes). http.trusted_proxies(REQUIRED with rate-limiting or audit logging). The rate-limiter and the audit log key onctx.Request().Ip(), which honoursX-Forwarded-For. Behind a proxy/CDN you must set the trusted-proxy list. If you do not, any client can forgeX-Forwarded-Forto bypass the login rate limit (each spoofed IP gets a fresh window) and to forge the IP recorded in the audit log. This is mandatory wheneverauthkit.features.audit_logor login rate-limiting is enabled.- Production cookie flags. Set
session.secure=trueand asession.same_siteoflax(typical for SPAs) orstrict. SameSite remains defense in depth in addition to Authkit's origin verification. - Shared limiter backend for multiple instances. The memory store cannot
coordinate processes. Register a Redis or equivalent atomic store before
routes when the app runs more than one instance. Store errors fail closed with
503 rate_limiter_unavailable. - TLS. Serve over HTTPS so the session cookie is never sent in clear.
When authkit.features.two_factor is on, users can enroll in TOTP
(RFC 6238, Google-Authenticator compatible, via github.com/pquerna/otp):
- The TOTP secret is encrypted at rest with the Goravel
Cryptfacade (app key); the column isjson:"-"and never serialized or logged. (It must be reversible so codes can be validated.) - Recovery codes are stored as one-way SHA-256 hashes, not reversibly
encrypted. The plaintext codes are shown exactly once, at confirmation /
regeneration; they can never be re-derived afterwards. The
GET /auth/two-factor/recovery-codesendpoint returns only the count of unused codes — if a user loses their codes they must regenerate. - Two-step login. A correct password for a 2FA user returns
{two_factor:true}and stashes only the user id in the session — the session is not authenticated until the challenge succeeds. - The challenge endpoint is rate-limited (same limiter as login) because a 6-digit code is brute-forceable.
- Recovery codes are single-use, stored hashed (see above), and can be regenerated (which invalidates the old set). Consumption is atomic (row-locked transaction) so a code cannot be double-spent under concurrency. The challenge accepts a TOTP code or a recovery code.
- TOTP codes are single-use within their window (OWASP ASVS 2.8.4): the accepted code's time-step is recorded and a replayed code is rejected. Validation allows ±1 period of clock skew.
- Disabling 2FA requires re-authentication with the account password, so a stolen session alone cannot silently remove 2FA.
When authkit.impersonation.enabled is on, an authorized actor can switch into
another user's session ("login as user") — within its own guard or across guards.
Because this bypasses the target's password by design, authorization is the
only gate, so it must be strong and the activity must be audited.
- Fail-closed gate. Impersonation is off by default. A per-actor-guard config
gate decides who may impersonate whom:
roles(the actor must hold one — empty = any authenticated user in this guard),target_guards(which guards may be targeted; empty = this guard cannot impersonate at all), andprotected_roles(targets holding one of these can never be impersonated). A guard with no gate config cannot impersonate (it can still be a target of another guard). - Optional host hook.
authkit.RegisterImpersonationPolicyregisters anauthkit.Impersonatorfor finer rules (tenant scoping, relationship checks). It runs only after the config gate has passed, so it can only ever tighten the decision — never loosen it. With no hook, the config gate alone decides. - Audited. Every switch and exit is written to the audit log
(
auth.impersonation_started/auth.impersonation_stopped, with actor/target ids and both guards in the metadata). - Ephemeral — no remember cookie. A switch issues no persistent remember-me cookie and no active-session tracking token, so it cannot be restored from a stored cookie; it lives only for the current session and ends on "stop".
- Target must be live. The target user must exist and must not be disabled;
protected_rolesblocks impersonating privileged accounts. - Reject while impersonating. While a switch is active, the guard blocks the
credential- and privilege-sensitive routes — password change
(
PUT /auth/password), user management (/auth/users*) and a nested impersonation — with403 impersonation_forbidden, so an actor acting "as" a user cannot change that user's password, manage users as them, or chain switches. - Session-id regeneration happens on both the switch and the stop
(anti-fixation), the same as login.
GET /auth/meexposesimpersonatedBywhile impersonating so a UI can show a banner and an exit control.
With multiple guards the domains share one session cookie on a single origin (Goravel keys each guard's user id separately). Active-session tracking is keyed by a stable per-guard token stored in the session — not the Goravel session id, which rotates on every login (anti-fixation). So concurrent logins to several guards in one browser all keep working: a second guard's login rotates the session id but the first guard's token (and its tracking row) survives.
For stronger isolation — so a flaw in one (e.g. customer) portal cannot reach another (e.g. admin) on the same origin — run each portal on its own subdomain/origin so the browser separates their cookies. A distinct per-guard session cookie name/path is an optional knob for single-origin setups.
- Single-role authorization only. There is one
rolestring per user and a single privileged role (admin, configurable viaauthkit.user_management_roles). The/usersendpoints are gated behind it (fail-closed default["admin"]), and the package keeps at least one active admin (you cannot delete, disable, demote, or self-disable the last admin). Reaching/usersalready requires admin, so an admin assigning any allowedroleis intended. Full roles/permissions tables (multiple privileges, per-resource grants) are a later phase. - No account lockout, email verification, or password reset in v1 — these are on the roadmap and need a configured mailer.
When EnableAuditLog is on, the package writes to the guard's audit table
(audit_logs by default, <guard>_audit_logs for a named guard):
| Action | When |
|---|---|
auth.login |
Successful login |
auth.login_remember |
Silent re-login from a remember cookie |
auth.login_failed |
Failed login (attempted email in metadata) |
auth.logout |
Logout |
auth.password_changed |
Self-service password change |
auth.api_token_created / auth.api_token_revoked / auth.api_tokens_revoked |
Personal API-token lifecycle |
user.create / user.update / user.delete |
User management |
user.password_reset |
Admin set-password |
auth.two_factor_enrolled / auth.two_factor_confirmed |
2FA enrollment |
auth.two_factor_disabled / auth.two_factor_recovery_regenerated |
2FA changes |
auth.two_factor_failed |
Failed 2FA challenge |
auth.impersonation_started / auth.impersonation_stopped |
An actor switched into / out of another user's session |
Audit writes are best-effort: a write failure is logged but does not fail the parent request.
Administrators can inspect successful sign-ins for their current guard through
GET {prefix}/auth/admin/logins. The endpoint is server-paginated, supports
literal user/IP filtering, sign-in-method filtering, and timestamp sorting. It
uses the same fail-closed roles as user management and is blocked while
impersonating.
It joins the audit actor to the live user only to display the current name and
email; deleted users retain the email snapshot stored with their audit event.
Correct http.trusted_proxies configuration remains mandatory because the
displayed IP comes from Goravel's trusted request IP resolution.
This is authentication code. If you find a security issue, please report it privately to the maintainers rather than opening a public issue.