User creation flows
Where users are created depends on provider, adapter, and ResolveUser. This guide maps every path.
Summary table
| Sign-in method | Who creates the user? | User ID source |
|---|---|---|
| OAuth + adapter (default) | Adapter.CreateUser in resolveUser | Adapter-generated UUID |
OAuth + ResolveUser | Your callback | Your DB primary key |
| OAuth JWT (no adapter) | No persistence | Provider profile id |
| Email magic link | CreateUser on first email | Adapter |
| OTP provider | Same as email | Adapter |
| Credentials | You in Authorize | Your Authorize return value |
| Passkey register | Existing session user | Already logged in |
| Passkey sign-in | Must exist (authenticator row) | Adapter user from credential |
Path A: OAuth with adapter (default)
flowchart TD
A[OAuth profile] --> B{Account linked?}
B -->|yes| C[GetUserByAccount]
B -->|no| D{Email taken?}
D -->|yes| E[OAuthAccountNotLinked error]
D -->|no| F[CreateUser]
F --> G[LinkAccount]
G --> H[Events.CreateUser]
No ResolveUser needed for basic apps.
// goauth creates:
// goauth_users row
// goauth_accounts row (provider + provider_account_id)
// goauth_sessions row (database strategy)
Path B: OAuth with ResolveUser (your schema)
Adapter may still create a goauth user first; ResolveUser runs after and replaces the session identity:
ResolveUser: func(ctx context.Context, p goauth.ResolveUserParams) (*goauth.User, error) {
id, err := myDB.UpsertFromOAuth(p.User, p.Account, p.Profile)
return &goauth.User{ID: id, Email: p.User.Email, Name: p.User.Name}, err
},
Use p.IsNewUser for welcome flows.
Path C: Credentials
You return the user from Authorize — goauth does not call CreateUser:
Authorize: func(ctx context.Context, creds map[string]string, r *http.Request) (*goauth.User, error) {
row, err := db.FindByEmail(creds["email"])
if err != nil || !bcrypt.Compare(row.Hash, creds["password"]) {
return nil, nil
}
return &goauth.User{
ID: strconv.FormatInt(row.ID, 10),
Email: row.Email,
Name: row.Name,
}, nil
},
Optional ResolveUser to reload enriched profile before session.
Path D: Email / OTP first visit
emailCallback in actions_email.go:
user, _ := adapter.GetUserByEmail(ctx, email)
if user == nil {
user, _ = adapter.CreateUser(ctx, &User{
Email: email,
EmailVerified: &now,
})
Events.CreateUser(ctx, user)
}
Custom creation instead of adapter tables
Callbacks: goauth.Callbacks{
ResolveUser: func(ctx context.Context, p goauth.ResolveUserParams) (*goauth.User, error) {
return myDB.FindOrCreateByEmail(p.User.Email)
},
},
Still need adapter for verification tokens unless you implement a custom adapter.
Path E: Passkey registration
User must already have a session (e.g. signed in via OAuth):
POST /auth/signin/passkey?action=register- Browser
credentials.create() POST /auth/callback/passkeyCreateAuthenticator+LinkAccount
New passkey users without prior account: sign up via email/OAuth first, then register passkey.
Discoverable passkey sign-in finds user via stored authenticator → GetUser(userId).
Linking multiple providers to one user
Default adapter policy: one email = one user. Second provider with same email → OAuthAccountNotLinked.
Advanced: use ResolveUser to merge accounts in your schema, or pre-link with LinkAccount in application code.
Email verification flag
Email provider sets EmailVerified on auto-create. OAuth may set from profile in provider Profile function:
Profile: func(p goauth.Profile, _ goauth.TokenSet) (*goauth.User, error) {
verified := p["email_verified"] == true
return &goauth.User{
ID: fmt.Sprint(p["sub"]),
Email: fmt.Sprint(p["email"]),
EmailVerified: &verified,
}, nil
},
Anti-patterns
| Don't | Do |
|---|---|
Create users in SignIn callback | Use ResolveUser or adapter |
| Return random ID on every OAuth login | Stable ID from DB |
| Store passwords in JWT | Only in your DB via Authorize |
See ResolveUser callback.