Email, password, and Google¶
This is the shape most consumer apps actually ship: users can sign up with an email and a password or with Google, and it's one account either way. The two base recipes already cover each door on its own. This one is about what happens where they meet: how a password account and a Google sign-in become a single user instead of two.
It assumes you've seen email and password (the model, the EmailSender) and
Sign in with Google (the OAuth client and wiring). Here we just turn both
on at once and focus on linking.
1. One config, both doors¶
The default AuthUserMixin already has everything for both paths: email and username login, a
password, and the google_id column OAuth needs. Turn on email= and oauth= together, with a
session transport and a public base URL:
import os
from crudauth import CRUDAuth, EmailConfig, OAuthCredentials, SessionTransport
from myapp.db import get_session
from myapp.models import User # class User(Base, AuthUserMixin)
from myapp.email import MySender # your EmailSender, from the email recipe
auth = CRUDAuth(
session=get_session, user_model=User, SECRET_KEY=os.environ["SECRET_KEY"],
redirect_base_url="https://app.example.com",
transports=[SessionTransport()],
email=EmailConfig(sender=MySender(), frontend_url="https://app.example.com"),
oauth={
"google": OAuthCredentials(
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
),
},
)
app.include_router(auth.router)
That mounts both front doors: the password door (/register, /login, plus verify and reset) and
the Google door (/oauth/google/authorize and its callback). Both end at the same kind of session
and resolve to the same Principal, so your current_user() gates never care which door a request
came through.
2. Linking: password first, then Google¶
Alice registers with alice@example.com and a password. Weeks later she clicks "Sign in with
Google" using that same Google account. Because Google reports the email as verified, CRUDAuth finds
her existing row by email and links the Google account to it (sets google_id) rather than
creating a second account. From then on she can use either door, and both land on the same user.
There is nothing to wire for this; finding-or-linking is what the callback does. The only thing you provide is that the two identities share an email address.
3. Linking: Google first, then a password¶
Bob comes in through Google first. He gets an account with no usable password (and, because Google
verified his address, email_verified=True). To let him also log in by password later, give him a
"set a password" affordance, which is just the existing password-reset flow:
curl -X POST http://localhost:8000/password/reset-request \
-H "Content-Type: application/json" -d '{"email":"bob@example.com"}'
# Bob clicks the emailed link; your page posts the token + the password he chose:
curl -X POST http://localhost:8000/password/reset-confirm \
-H "Content-Type: application/json" \
-d '{"token":"eyJ...","new_password":"a-strong-one"}'
After that, both doors work for Bob too. "Set a password" and "reset a password" are the same endpoint; a Google-first user simply has no password to begin with.
4. The rule that keeps linking safe¶
The reason this coexistence is not an account-takeover hole: CRUDAuth uses a provider email
only when the provider reports it verified. If someone points a Google account with an
unverified alice@example.com at the callback, it neither links to Alice nor creates an account;
the callback fails with email_unverified. An attacker can't attach their Google login to your
account using an address they haven't proven they control.
It also covers the opposite order. If an attacker registers alice@example.com with a password
before Alice ever signs up, and never verifies it, Alice's verified Google sign-in claims that
account: the attacker's password stops working, their sessions are signed out, and the email is
marked verified. If Alice had verified the address herself, her password and sessions are kept.
5. Who has to verify their email¶
The two doors arrive in different states, and the verified gate handles both uniformly:
- A Google user usually arrives
email_verified=True(Google verified the address), so they skip your verification flow. - A password user is
email_verified=Falseuntil they go through verify-request / verify-confirm.
current_user(verified=True) reads that one flag regardless of how the user signed up, so a route
that needs a proven email treats both doors the same.
Where to go next¶
- Add GitHub beside Google: one more entry in
oauth={...}and a button at/oauth/github/authorize(the mixin already hasgithub_id). - Set app columns on either signup path: server-controlled fields run on both password and OAuth creates.
- The single-door versions: email and password, Sign in with Google.