A runtime-agnostic authentication library for Go, modeled closely on the
architecture of Auth.js. If you know Auth.js, you already
know goauth: the same concepts (providers, callbacks, events, adapters, JWT and
database sessions) mapped onto idiomatic Go and the standard net/http handler
interface.
import "github.com/izetmolla/goauth"
| Auth.js | goauth |
|---|---|
Auth(request, config) |
goauth.New(cfg) returns an http.Handler |
providers/* |
goauth/providers (GitHub, Google, OAuth, OIDC, Credentials, Email) |
Adapters (@auth/*-adapter) |
goauth.Adapter interface + goauth/adapters/memory |
callbacks (signIn/jwt/session/...) |
goauth.Config.Callbacks |
events |
goauth.Config.Events |
| Encrypted JWT sessions (JWE) | goauth/jwt (wire-compatible dir + A256CBC-HS512) |
| Routes: session/csrf/providers/... | The same routes under BasePath (default /auth) |
The handler serves the same fixed action table as Auth.js core:
| Method | Path | Purpose |
|---|---|---|
| GET | /auth/session |
Current session JSON ({} if none) |
| GET | /auth/csrf |
Signed double-submit CSRF token |
| GET | /auth/providers |
Configured providers |
| POST | /auth/signin/{provider} |
Begin sign-in (OAuth redirect, etc.) |
| GET | /auth/callback/{provider} |
OAuth/OIDC and magic-link callback |
| POST | /auth/callback/{provider} |
Credentials sign-in |
| POST | /auth/signout |
Clear the session |
| POST | /auth/mfa/verify |
Submit OTP code (when MFA enabled) |
| GET | /auth/mfa/device |
Check trusted device (userId, deviceId) |
| GET | /auth/sessions |
List active sessions |
| DELETE | /auth/sessions?token=… |
Revoke a specific session |
package main
import (
"log"
"net/http"
"github.com/izetmolla/goauth"
"github.com/izetmolla/goauth/providers/github"
)
func main() {
auth, err := goauth.New(goauth.Config{
Secret: []string{"a-32+-byte-random-secret-please-change"},
TrustHost: true, // trust the request Host (set false + URL in prod behind no proxy)
Providers: []goauth.Provider{
github.New("CLIENT_ID", "CLIENT_SECRET"),
},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/auth/", auth) // mount the action handlers
// Protect routes with the session helper.
mux.HandleFunc("/me", func(w http.ResponseWriter, r *http.Request) {
session, _ := auth.GetSession(w, r)
if session == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Write([]byte("hello " + session.User.Email))
})
log.Fatal(http.ListenAndServe(":3000", mux))
}Like Auth.js, the strategy defaults intelligently:
- No adapter → JWT (stateless, encrypted cookie).
- Adapter configured → database (sessions persisted via the adapter).
- A
Credentialsprovider forces JWT (database sessions are not supported for credentials, matching Auth.js).
Override explicitly with Config.Session.Strategy.
Each provider lives in its own subpackage under providers/, so you import
only the ones you use:
import (
"github.com/izetmolla/goauth/providers/github"
"github.com/izetmolla/goauth/providers/google"
"github.com/izetmolla/goauth/providers/apple"
"github.com/izetmolla/goauth/providers/oauth"
"github.com/izetmolla/goauth/providers/oidc"
"github.com/izetmolla/goauth/providers/credentials"
"github.com/izetmolla/goauth/providers/email"
"github.com/izetmolla/goauth/providers/otp"
)
github.New(id, secret)
google.New(id, secret) // OIDC discovery
apple.New(clientID, secret) // ES256 client secret via apple.ClientSecret
oauth.New(oauth.Options{ /* generic OAuth 2.0 */ })
oidc.New(oidc.Options{ Issuer: "https://issuer" })
credentials.New(credentials.Options{ Authorize: ... })
email.New(email.Options{ SendVerificationRequest: ... }) // magic link; needs adapter
otp.New(otp.Options{ SendCode: ... }) // email one-time code; needs adapterSee providers/README.md for full examples, including
Sign in with Apple (form_post + JWT client secret) and the email OTP flow.
goauth.Config{
Callbacks: goauth.Callbacks{
SignIn: func(ctx context.Context, p goauth.SignInCallbackParams) (bool, error) {
return strings.HasSuffix(p.User.Email, "@trusted.com"), nil
},
JWT: func(ctx context.Context, p goauth.JWTCallbackParams) (goauth.JWT, error) {
if p.User != nil {
p.Token["role"] = "member"
}
return p.Token, nil
},
Session: func(ctx context.Context, p goauth.SessionCallbackParams) (*goauth.Session, error) {
return p.Session, nil
},
},
}Enable a second factor for credentials sign-ins. After a successful password check, goauth sends a one-time code and returns a challenge token. The client submits the code to complete sign-in. Users can opt to trust the device so future logins skip the OTP step.
goauth.Config{
MFA: goauth.MFAConfig{
Enabled: true,
CodeLength: 6, // default
MaxAge: 10 * time.Minute, // default
TrustDeviceMaxAge: 90 * 24 * time.Hour, // default; 0 disables
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
return sendEmail(p.Email, "Your code: "+p.Code)
},
},
}POST /auth/signin/credentialswith email + password → if MFA is enabled and the device is not trusted, returns{"challenge":"…","expiresIn":600}instead of a session.- The user receives the code via email/SMS.
POST /auth/mfa/verifywithchallenge=…&code=123456(optionallytrustDevice=true) → completes the sign-in (returns session cookie or tokens).
When the user sets trustDevice=true in the verify request, a long-lived
encrypted cookie (goauth.trusted-device) is set. On subsequent logins from
that browser the OTP step is skipped. The cookie lifetime is configurable via
MFA.TrustDeviceMaxAge (default 90 days). Set it to a negative value to disable
device trust entirely.
Pass a stable deviceId (form field, query, or X-Device-Id header) on
credentials sign-in and MFA verify. Check trust before prompting for OTP:
GET /auth/mfa/device?userId=<id>&deviceId=<device>
→ { "trusted": true, "skipMfa": true }For mobile or server-side trust stores, implement MFA.IsDeviceTrusted and
MFA.TrustDevice with goauth.MFADeviceTrustParams{UserID, DeviceID}.
Authenticated users can list and revoke their active sessions via the
/auth/sessions endpoint. This requires an adapter that implements the
SessionLister interface (all bundled adapters do).
| Method | Path | Purpose |
|---|---|---|
| GET | /auth/sessions |
List active sessions |
| DELETE | /auth/sessions?token=… |
Revoke a specific session |
// List sessions
GET /auth/sessions
Authorization: Bearer <accessToken>
// Response: [{"sessionToken":"…","userId":"…","expires":"…"}, …]
// Revoke a session
DELETE /auth/sessions?token=<sessionToken>
Authorization: Bearer <accessToken>This lets users see all devices where they're signed in and revoke any session they no longer trust — similar to "Sign out of all devices" in Google/GitHub.
Browsers use cookies, but mobile apps want tokens they can store and send. Enable the token flow and goauth will, on request, return an access token and refresh token as JSON instead of setting a cookie and redirecting.
goauth.Config{
Tokens: goauth.TokensConfig{
Enabled: true, // serve /auth/token + accept Bearer auth
AccessTokenMaxAge: 15 * time.Minute, // default
RefreshTokenMaxAge: 30 * 24 * time.Hour,
// AlwaysReturn: true, // make this a pure API (never cookies)
},
}A client opts in per request with the X-Auth-Flow: token header (or
?flow=token). Sign-in then returns:
- Authenticate requests: send
Authorization: Bearer <accessToken>.GetSessionvalidates it automatically (no cookie needed). - Refresh:
POST /auth/tokenwithrefresh_token=<token>(form) or{"refreshToken":"…"}(JSON) → a fresh access/refresh pair. - CSRF is skipped for token-flow sign-in (no ambient cookie credentials).
React Native sketch:
const r = await fetch(`${API}/auth/callback/credentials`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Auth-Flow": "token" },
body: new URLSearchParams({ email, password }),
});
const { accessToken, refreshToken } = await r.json();
// store securely, then: fetch(`${API}/me`, { headers: { Authorization: `Bearer ${accessToken}` } })github.com/izetmolla/fiberauth is a
separate module that adapts the handler to Fiber v3,
like the framework packages in Auth.js. It lives outside this module so the
goauth core never pulls in Fiber; add it only if you use Fiber:
go get github.com/izetmolla/fiberauth
import (
"github.com/gofiber/fiber/v3"
"github.com/izetmolla/fiberauth"
)
app := fiber.New()
app.All("/auth/*", fiberauth.Handler(auth)) // mount actions
app.Get("/me", fiberauth.Protect(auth), func(c fiber.Ctx) error {
return c.JSON(fiberauth.SessionFrom(c)) // requires a session
})
// or non-blocking: app.Use(fiberauth.SessionLoader(auth)) then fiberauth.SessionFrom(c)Guard requires a session and that every Authorizer passes — 401 when
there's no session, 403 when an authorizer rejects. Compose the built-in
authorizers or write your own:
// Require a role (reads the "roles" array or "role" string claim).
app.Get("/admin", fiberauth.Guard(auth, fiberauth.HasRole("admin")), adminHandler)
// Multiple conditions (all must pass): a role AND a claim AND a custom rule.
// HasRole accepts several roles and passes if the session has any of them.
app.Get("/billing", fiberauth.Guard(auth,
fiberauth.HasRole("admin", "billing"),
fiberauth.HasClaim("plan", "pro"),
fiberauth.Condition(func(c fiber.Ctx, s *goauth.Session) bool {
return c.Params("orgId") == s.StringClaim("org")
}),
), billingHandler)
// Custom responses:
app.Get("/x", fiberauth.GuardWithConfig(auth, fiberauth.GuardConfig{
Authorizers: []fiberauth.Authorizer{fiberauth.HasRole("admin")},
Unauthorized: func(c fiber.Ctx) error { return c.Redirect().To("/login") },
Forbidden: func(c fiber.Ctx) error { return c.Status(403).JSON(fiber.Map{"error": "nope"}) },
}))Roles and custom fields come from the JWT — populate them in the JWT callback
(e.g. p.Token["roles"] = []string{"admin"}); they surface on the session via
session.Roles(), session.HasRole(), session.Claim() and StringClaim().
Implement goauth.Adapter to back the library with any database. Bundled
backends (each with its own README):
| Package | Backend | Docs |
|---|---|---|
adapters/memory |
in-memory (tests/prototypes) | memory |
adapters/postgres |
PostgreSQL | postgres |
adapters/mysql |
MySQL | mysql |
adapters/mariadb |
MariaDB | mariadb |
adapters/redis |
Redis & Redis Cluster | redis |
adapters/mongodb |
MongoDB | mongodb |
db, _ := sql.Open("pgx", dsn)
adapter := postgres.New(db)
// Tables are created automatically when the adapter is passed to goauth.New.
auth, _ := goauth.New(goauth.Config{ Adapter: adapter, /* … */ })All adapters that implement the Migrator interface (postgres, mysql, mariadb)
run their schema migration automatically during goauth.New() — no separate
Migrate call is needed. You can still call adapter.Migrate(ctx) yourself if
you prefer explicit control.
Every adapter namespaces its tables/keys/collections with a prefix that defaults
to goauth_; override it with WithPrefix("…"). All generated IDs
(users, accounts, sessions) use UUID v4 (stdlib crypto/rand). See
adapters/README.md for the overview and each
adapter's README for setup details (drivers, migrations, Redis Cluster, Mongo
indexes, …).
Unsafe actions (POST /signout, credentials and email sign-in) require the
signed double-submit token from GET /auth/csrf, submitted as the csrfToken
form field — identical to the Auth.js client contract.
This is a from-scratch Go port focused on the core architecture: OAuth 2.0/OIDC,
credentials and email providers; JWT and database sessions; CSRF, PKCE, state
and nonce checks; callbacks, events and pluggable adapters. See
.cursor/rules/ for conventions when extending it.
Izet Molla
- GitHub: github.com/izetmolla
- Email: izetmolla@icloud.com
{ "sessionId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", // UUID v4 — stable session identifier "user": { "id": "1", "name": "Demo", "email": "demo@example.com" }, "accessToken": "eyJhbGc…", // short-lived JWE; send as Authorization: Bearer "refreshToken": "eyJhbGc…", // long-lived; exchange at POST /auth/token "tokenType": "Bearer", "expiresIn": 900, "expiresAt": 1780478790 }