Skip to main content

User creation flows

Where users are created depends on provider, adapter, and ResolveUser. This guide maps every path.

Summary table

Sign-in methodWho creates the user?User ID source
OAuth + adapter (default)Adapter.CreateUser in resolveUserAdapter-generated UUID
OAuth + ResolveUserYour callbackYour DB primary key
OAuth JWT (no adapter)No persistenceProvider profile id
Email magic linkCreateUser on first emailAdapter
OTP providerSame as emailAdapter
CredentialsYou in AuthorizeYour Authorize return value
Passkey registerExisting session userAlready logged in
Passkey sign-inMust 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):

  1. POST /auth/signin/passkey?action=register
  2. Browser credentials.create()
  3. POST /auth/callback/passkey
  4. CreateAuthenticator + 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'tDo
Create users in SignIn callbackUse ResolveUser or adapter
Return random ID on every OAuth loginStable ID from DB
Store passwords in JWTOnly in your DB via Authorize

See ResolveUser callback.