Skip to content

Sign in by email with a link and a code, alongside Google - #53

Merged
mykolamelnykml merged 1 commit into
mainfrom
email-sign-in
Oct 9, 2026
Merged

mykolamelnykml merged 1 commit into
mainfrom
email-sign-in

Conversation

@mykolamelnykml

Copy link
Copy Markdown
Contributor

Readers without Google can now sign in by email. One message carries both a Sign in link and a six-digit code, and either one signs the reader in. A first sign-in creates the account and sends the usual welcome email. Google sign-in is unchanged.

Why not allauth's login-by-code

allauth binds its code flow to the X-Session-Token of the browser that asked, so a link opened on a phone could not finish a sign-in started on a laptop. It also has no link variant. apps/accounts/email_login.py is a small service of our own that ends in the same JWT pair as the Google exchange, so refresh and rotation are unchanged.

Backend

  • POST /api/v1/auth/email-login {email, next}
    • Answers 204 for every address, so the form reveals no accounts.
    • A repeat within 60 seconds sends nothing.
    • The mail goes out in the request, never queued: it carries the raw code and link, and django-q would store them.
  • POST /api/v1/auth/email-login/confirm {email, code} or {token} returns the token pair plus user.
    • The code and link share one row, stored as SHA-256 only. Using one spends both.
    • A row dies after 15 minutes, 5 wrong codes, or a newer request.
    • An inactive account is refused.
  • Both endpoints are throttled per IP: EMAIL_LOGIN_REQUEST_RATE (10/hour) and EMAIL_LOGIN_CONFIRM_RATE (30/hour), documented in .env.example.
  • Verified address: signing in records a verified allauth EmailAddress, so a later Google sign-in with the same address lands on the same account.
  • New email template: login_link.
  • Wording: claim_invite now says "Sign in" rather than "Sign in with Google".

Frontend

  • /auth/signin: "Continue with Google" stays, and an email form sits below it. After sending, the form switches to a code box with a "Use a different email" button.
  • next-auth provider: a Credentials provider with id email. Its authorize makes the confirm call, and resolveAuthToken keeps the pair.
  • /auth/email/verify: signs in on a button, never on load, because mail scanners open links. A spent or expired link goes to /auth/error?error=EmailLinkInvalid.
  • Landing paths: landingPath keeps every landing on this site. next-auth's signIn() sends an absolute callbackUrl, and //host or /\host fall back to /account.
  • Sign-in buttons: the header, comment and claim buttons now open the sign-in page rather than going straight to Google.

Testing

  • make backend-test passes (795), and tests/test_email_login.py has 22 cases.
  • make frontend-test passes (951).
  • make lint is clean, and make schema leaves the tree clean.
  • Manual check against the local stack:
    • The link from Mailpit, opened in a fresh browser, signed in and landed on next, and /account showed the new account.
    • Opening the same link again went to the error page.
    • Typing the code on /auth/signin showed an inline error for a wrong code, and the right code signed in and redirected.

Deploy note

Migration accounts.0003_email_login adds the accounts_email_login table.

The sign-in page keeps "Continue with Google" and adds an email form. One
message carries both a "Sign in" link and a six-digit code, and either one
signs the reader in. A first sign-in creates the account and sends the usual
welcome email.

The service is ours (`apps/accounts/email_login.py`), not allauth's
login-by-code: that flow is bound to the browser that asked, so its link
could not finish on a phone a sign-in started on a laptop. It ends in the
same JWT pair as the Google exchange, so refresh and rotation are unchanged.

- `POST /auth/email-login` answers 204 for every address, sends nothing on
  a repeat within 60 seconds, and mails in the request, never queued: the
  mail carries the raw code and link.
- `POST /auth/email-login/confirm` takes `{email, code}` or `{token}`. The
  code and link share one row, stored as SHA-256 only, so using one spends
  both. A row dies after 15 minutes, 5 wrong codes, or a newer request.
- Both endpoints are throttled per IP (`EMAIL_LOGIN_REQUEST_RATE`,
  `EMAIL_LOGIN_CONFIRM_RATE`).
- Signing in records a verified allauth `EmailAddress`, so a later Google
  sign-in with the same address lands on the same account.
- Frontend: a next-auth `Credentials` provider with id `email`. The code is
  typed on `/auth/signin`; the link opens `/auth/email/verify`, which signs
  in on a button, never on load, because mail scanners open links.
- `landingPath` keeps every landing on this site. The header, comment and
  claim buttons now open the sign-in page rather than Google directly.
- DEVELOPMENT.md and CLAUDE.md describe the flow.
@mykolamelnykml
mykolamelnykml merged commit fc07d42 into main Oct 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant