diff --git a/CLAUDE.md b/CLAUDE.md
index fb6e47c..8a1f50c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,13 +44,18 @@ make schema # regenerates openapi.json AND the TypeScript client
## Auth
The frontend never uses session cookies against `/api/`. It gets a Google ID
-token, trades it at `/_allauth/app/v1/auth/provider/token` for our own JWT pair,
+token (or an emailed code - below), trades it at `/_allauth/app/v1/auth/provider/token` for our own JWT pair,
and sends the access token as a bearer. Refresh tokens **rotate** — the new one
must replace the old. Access tokens live 15 minutes.
Session cookies still exist, but only for the Django admin and the
server-rendered `/accounts/` allauth views.
+**Email sign-in is ours, not allauth's** (`apps/accounts/email_login.py`, next-auth provider
+`email`). One mail carries a code and a link that share one row, so using either spends both. It
+is sent in the request, never queued, because it carries the raw secrets. Requesting answers 204
+for every address. The verify page acts on a button, never on load.
+
Google credentials come from environment variables, **not** a `SocialApp` row in
the admin — configuring both raises `MultipleObjectsReturned`.
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 9f080a0..a970da9 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -120,9 +120,39 @@ access, at the next request. Signing uses `JWT_SIGNING_KEY`, falling back to `SE
A browser redirect flow (`/_allauth/app/v1/auth/provider/redirect`) and the server-rendered
`/accounts/` views are also available; the latter is how you sign into the Django admin with Google.
-Email/password and magic-link login are deliberately switched off for now
-(`SOCIALACCOUNT_ONLY = True` in `config/settings/base.py`); allauth already ships both, so enabling
-them later is a settings change plus frontend work.
+### Signing in by email
+
+Readers without Google sign in by email. One message carries both a six-digit code and a link, and
+either one signs in. The first sign-in creates the account (and sends `welcome`). The endpoints are
+ours (`apps/accounts/email_login.py`), not allauth's: allauth's login-by-code is bound to the
+`X-Session-Token` of the browser that asked, so its link could not finish on a phone a sign-in
+started on a laptop. allauth's own local login stays off (`SOCIALACCOUNT_ONLY = True`).
+
+```
+# 1. mail a code and a link - 204 whatever the address, so the form reveals no accounts
+POST /api/v1/auth/email-login { "email": "...", "next": "/tools/acme" }
+
+# 2a. the code, typed on /auth/signin 2b. the link, /auth/email/verify?token=...&next=...
+POST /api/v1/auth/email-login/confirm { "email": "...", "code": "042917" } | { "token": "..." }
+-> { "access_token", "refresh_token", "token_type", "expires_in", "user": {...} }
+```
+
+The pair is the same one Google's exchange returns, so refreshing it is step 3 above. On the
+frontend it is a next-auth `Credentials` provider with id `email`: its `authorize` makes the confirm
+call, and `resolveAuthToken` keeps the pair.
+
+What bites:
+
+- **The code and link share one row**, stored as SHA-256 only: using one spends the other, a new
+ request spends the previous mail's, and the row dies after 15 minutes or 5 wrong codes.
+- **The mail is sent in the request, never queued**, like `claim_code`.
+- **A repeat within 60 seconds sends nothing** and still answers 204. Both endpoints are also
+ throttled per IP (`EMAIL_LOGIN_REQUEST_RATE`, `EMAIL_LOGIN_CONFIRM_RATE`).
+- **The verify page signs in on a button, never on load**, because mail scanners open links.
+- **Signing in records a verified allauth `EmailAddress`.** That is what lets a later Google
+ sign-in with the same address land on the same account.
+- **`next` is a path on this site or nothing.** The backend falls back to `/account`, and
+ `landingPath` (`src/lib/auth/landing.ts`) strips the origin that next-auth's `signIn()` adds.
## Adding an endpoint
@@ -291,7 +321,8 @@ production. Tests always use the in-memory backend.
| Email | Sent when | To |
| --- | --- | --- |
-| `welcome` | allauth's `user_signed_up` - a first Google sign-in, not an account made in the admin | the new user |
+| `welcome` | allauth's `user_signed_up` - a first Google or email sign-in, not an account made in the admin | the new user |
+| `login_link` | someone asks to sign in by email (`POST /auth/email-login`) | that address |
| `submission_received` | a tool is submitted (`POST /catalog/submissions`) | `contact_email`, else the account |
| `submission_staff_notice` | the same, with a link to the row in the admin | every active `is_staff` account |
| `claim_code` | a vendor claims a listing | the claimant's work email |
@@ -315,8 +346,8 @@ re-queued with the admin's **Re-score** action, `services.rescore`. It runs only
submission that is scoring, failed or awaiting review, and settles it - emails included -
like any other.
-`claim_code` and `claim_invite` are sent inside the request, never queued: a task's arguments are
-kept in the database, and both carry a credential the server otherwise stores only as a hash. All
+`claim_code`, `claim_invite` and `login_link` are sent inside the request, never queued: a task's arguments are
+kept in the database, and each carries a credential the server otherwise stores only as a hash. All
the others go through the django-q2 queue after the transaction commits, so a
sign-in or a submission never waits on - or fails with - the mail provider, and a
rolled-back request mails nobody. On the host that means they only leave while
diff --git a/backend/.env.example b/backend/.env.example
index c86ecb8..727b70b 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -98,6 +98,9 @@ PUBLIC_MEDIA_URL=http://localhost:8007/media
# MAX_API_KEYS_PER_USER=10
# Per-IP rate at which `pdfredeval login` may open a sign-in.
# CLI_LOGIN_START_RATE=20/hour
+# Per-IP rates for signing in by email: requesting a code and link, and using one.
+# EMAIL_LOGIN_REQUEST_RATE=10/hour
+# EMAIL_LOGIN_CONFIRM_RATE=30/hour
# --- Staff MCP server -----------------------------------------------------
# /mcp, with its OAuth authorization server at /oauth/. No required variables;
diff --git a/backend/apps/accounts/api.py b/backend/apps/accounts/api.py
index 7ecd5bf..f2838ac 100644
--- a/backend/apps/accounts/api.py
+++ b/backend/apps/accounts/api.py
@@ -1,7 +1,8 @@
"""API-side authentication: the bearer token scheme and the `/auth` router.
-Obtaining tokens is allauth's job (`/_allauth/app/v1/...`, including
-`auth/tokens/refresh`); this module only consumes them.
+Obtaining tokens is mostly allauth's job (`/_allauth/app/v1/...`, including
+`auth/tokens/refresh`). The exception is signing in by email (`email_login.py`),
+which ends here in the same token pair.
"""
from django.conf import settings
@@ -13,7 +14,7 @@
from ninja_apikey.models import APIKey
from ninja_apikey.security import APIKeyAuth, generate_key
-from . import cli_login, jwt, mcp_oauth
+from . import cli_login, email_login, jwt, mcp_oauth
from .schemas import (
ApiKeyCreatedOut,
ApiKeyIn,
@@ -23,6 +24,9 @@
CliLoginStartOut,
CliLoginTokenIn,
CliLoginTokenOut,
+ EmailLoginConfirmIn,
+ EmailLoginIn,
+ EmailLoginTokenOut,
McpAuthorizationDecisionIn,
McpAuthorizationDecisionOut,
McpAuthorizationOut,
@@ -228,6 +232,55 @@ def deny_cli_login(request: HttpRequest, user_code: str):
raise _cli_error(exc) from exc
+# --- Signing in by email ----------------------------------------------------------
+# A code and a link in one message: see apps/accounts/email_login.py.
+
+
+class EmailLoginRequestThrottle(AnonRateThrottle):
+ scope = "email_login_request"
+
+ def __init__(self):
+ super().__init__(settings.EMAIL_LOGIN_REQUEST_RATE)
+
+
+class EmailLoginConfirmThrottle(AnonRateThrottle):
+ scope = "email_login_confirm"
+
+ def __init__(self):
+ super().__init__(settings.EMAIL_LOGIN_CONFIRM_RATE)
+
+
+@router.post(
+ "/email-login",
+ response={204: None},
+ throttle=[EmailLoginRequestThrottle()],
+ summary="Email a sign-in code and link",
+)
+def request_email_login(request: HttpRequest, payload: EmailLoginIn):
+ try:
+ email_login.request_login(payload.email, payload.next)
+ except email_login.InvalidAddress as exc:
+ raise HttpError(422, str(exc)) from exc
+ return Status(204, None)
+
+
+@router.post(
+ "/email-login/confirm",
+ response=EmailLoginTokenOut,
+ throttle=[EmailLoginConfirmThrottle()],
+ summary="Sign in with an emailed code or link",
+)
+def confirm_email_login(request: HttpRequest, payload: EmailLoginConfirmIn):
+ try:
+ if payload.token:
+ user = email_login.confirm_with_link(payload.token)
+ else:
+ user = email_login.confirm_with_code(payload.email, payload.code)
+ except email_login.EmailLoginError as exc:
+ raise HttpError(400, str(exc)) from exc
+ return {**jwt.issue_token_pair(user), "user": user}
+
+
# --- MCP connector consent ---------------------------------------------------------
# The authorize step of the /mcp OAuth flow: see apps/accounts/mcp_oauth.py. Staff
# only, because a code for anyone else mints a token the MCP server refuses anyway.
diff --git a/backend/apps/accounts/email_login.py b/backend/apps/accounts/email_login.py
new file mode 100644
index 0000000..24e25d2
--- /dev/null
+++ b/backend/apps/accounts/email_login.py
@@ -0,0 +1,151 @@
+"""Signing in by email: one message with a six-digit code and a link.
+
+Transport-free, like `cli_login.py`: the routes in `api.py` call these and map
+`EmailLoginError` to HTTP. A successful sign-in ends in the same JWT pair Google's
+does (`jwt.issue_token_pair`), so the frontend refreshes both the same way.
+
+The mail is sent in the request, never queued: django-q keeps task arguments in the
+database, which would store the raw code and link.
+"""
+
+import hashlib
+import secrets
+from datetime import timedelta
+from urllib.parse import urlencode
+
+from allauth.account.models import EmailAddress
+from allauth.account.signals import user_signed_up
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.db import transaction
+from django.utils import timezone
+
+from apps.accounts.models import EmailLogin
+from apps.core.email import send_templated_email
+
+LIFETIME = timedelta(minutes=15)
+# Wrong codes before the row is dead. Six digits against five guesses is 1 in 200,000.
+MAX_ATTEMPTS = 5
+# Per address, between mails: what stops the form being used to flood one inbox.
+COOLDOWN = timedelta(seconds=60)
+DEFAULT_NEXT = "/account"
+
+
+class InvalidAddress(Exception):
+ """The address is malformed - the one refusal requesting may give."""
+
+
+class EmailLoginError(Exception):
+ """Any failure to sign in. One message for all of them, so none leaks which."""
+
+ def __init__(self):
+ super().__init__("That code or link is wrong or has expired. Request a new one.")
+
+
+def request_login(email, next_path=""):
+ """Mail a code and a link to `email`. Says nothing about whether it has an account."""
+ email = email.strip().lower()
+ try:
+ validate_email(email)
+ except ValidationError as exc:
+ raise InvalidAddress("Enter a valid email address.") from exc
+ recent = timezone.now() - COOLDOWN
+ if EmailLogin.objects.filter(email=email, created_at__gt=recent).exists():
+ return
+ if get_user_model().objects.filter(email__iexact=email, is_active=False).exists():
+ return
+ code = f"{secrets.randbelow(1_000_000):06d}"
+ link = secrets.token_urlsafe(32)
+ # One transaction with the send: a mail that never left must not start the cooldown.
+ with transaction.atomic():
+ # Only the newest mail works: an older one left in an inbox is one more to steal.
+ EmailLogin.objects.filter(email=email, used_at__isnull=True).update(used_at=timezone.now())
+ EmailLogin.objects.create(
+ email=email,
+ code_hash=_hash(code),
+ link_hash=_hash(link),
+ expires_at=timezone.now() + LIFETIME,
+ )
+ send_templated_email(
+ "login_link",
+ {
+ "code": code,
+ "link": _link_url(link, next_path),
+ "ttl_minutes": int(LIFETIME.total_seconds() // 60),
+ },
+ to=[email],
+ )
+
+
+def confirm_with_code(email, code):
+ """Sign in with the code. Returns the user or raises `EmailLoginError`."""
+ with transaction.atomic():
+ login = _open().filter(email=email.strip().lower(), attempts__lt=MAX_ATTEMPTS).first()
+ if login is not None and secrets.compare_digest(login.code_hash, _hash(code.strip())):
+ return _sign_in(login)
+ if login is not None:
+ # Committed, not raised inside the transaction: a rollback would undo the count.
+ login.attempts += 1
+ login.save(update_fields=["attempts"])
+ raise EmailLoginError()
+
+
+def confirm_with_link(token):
+ """Sign in with the link's token, on any device. Same return as `confirm_with_code`."""
+ with transaction.atomic():
+ login = _open().filter(link_hash=_hash(token.strip())).first()
+ if login is None:
+ raise EmailLoginError()
+ return _sign_in(login)
+
+
+def _open():
+ """Rows that can still sign someone in, locked for the rest of the transaction."""
+ return EmailLogin.objects.select_for_update().filter(
+ used_at__isnull=True, expires_at__gt=timezone.now()
+ )
+
+
+def _sign_in(login):
+ user = get_user_model().objects.filter(email__iexact=login.email).first()
+ if user is not None and not user.is_active:
+ raise EmailLoginError()
+ login.used_at = timezone.now()
+ login.save(update_fields=["used_at"])
+ if user is None:
+ user = get_user_model().objects.create_user(login.email)
+ # The signal a Google sign-up sends, so the welcome mail goes out the same way.
+ user_signed_up.send(sender=type(user), request=None, user=user)
+ _mark_verified(user)
+ return user
+
+
+def _mark_verified(user):
+ """Record what the mail just proved. allauth connects a later Google sign-in with the
+ same address to this account only through a verified `EmailAddress`."""
+ address = EmailAddress.objects.filter(user=user, email__iexact=user.email).first()
+ if address is None:
+ address = EmailAddress(user=user, email=user.email)
+ address.verified = True
+ if not EmailAddress.objects.filter(user=user, primary=True).exclude(pk=address.pk).exists():
+ address.primary = True
+ address.save()
+
+
+def _link_url(link, next_path):
+ query = urlencode({"token": link, "next": _safe_next(next_path)})
+ return f"{settings.FRONTEND_URL}/auth/email/verify?{query}"
+
+
+def _safe_next(path):
+ """A path on this site, or the account page: the link must not bounce anyone elsewhere."""
+ path = (path or "").strip()
+ if not path.startswith("/") or path.startswith(("//", "/\\")):
+ return DEFAULT_NEXT
+ return path
+
+
+def _hash(secret):
+ return hashlib.sha256(secret.encode()).hexdigest()
diff --git a/backend/apps/accounts/migrations/0003_email_login.py b/backend/apps/accounts/migrations/0003_email_login.py
new file mode 100644
index 0000000..b921077
--- /dev/null
+++ b/backend/apps/accounts/migrations/0003_email_login.py
@@ -0,0 +1,30 @@
+# Generated by Django 6.1.1 on 2026-10-08 19:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0002_cli_login'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='EmailLogin',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email', models.EmailField(db_index=True, max_length=254)),
+ ('code_hash', models.CharField(max_length=64)),
+ ('link_hash', models.CharField(max_length=64, unique=True)),
+ ('attempts', models.PositiveSmallIntegerField(default=0)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('expires_at', models.DateTimeField()),
+ ('used_at', models.DateTimeField(blank=True, null=True)),
+ ],
+ options={
+ 'db_table': 'accounts_email_login',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py
index fd64a91..b398566 100644
--- a/backend/apps/accounts/models.py
+++ b/backend/apps/accounts/models.py
@@ -110,3 +110,27 @@ def __str__(self):
@property
def expired(self):
return self.expires_at <= timezone.now()
+
+
+class EmailLogin(models.Model):
+ """One emailed sign-in: a six-digit code and a link, either of which signs in once.
+
+ Both secrets are stored only as SHA-256. They share the row, so using one spends the
+ other. Nothing ties the row to the browser that asked, which is what lets the link
+ finish on a phone a sign-in started on a laptop.
+ """
+
+ email = models.EmailField(db_index=True)
+ code_hash = models.CharField(max_length=64)
+ link_hash = models.CharField(max_length=64, unique=True)
+ attempts = models.PositiveSmallIntegerField(default=0)
+ created_at = models.DateTimeField(auto_now_add=True)
+ expires_at = models.DateTimeField()
+ used_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ db_table = "accounts_email_login"
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return f"{self.email} ({self.created_at:%Y-%m-%d %H:%M})"
diff --git a/backend/apps/accounts/schemas.py b/backend/apps/accounts/schemas.py
index 5653cb6..10212af 100644
--- a/backend/apps/accounts/schemas.py
+++ b/backend/apps/accounts/schemas.py
@@ -107,3 +107,25 @@ class McpAuthorizationDecisionIn(Schema):
class McpAuthorizationDecisionOut(Schema):
#: The client's redirect URI with the code or the refusal on it. Send the browser there.
redirect_url: str
+
+
+class EmailLoginIn(Schema):
+ email: str = Field(min_length=3, max_length=254)
+ #: Where the link lands after signing in. A path on this site, or it is ignored.
+ next: str = Field(default="", max_length=500)
+
+
+class EmailLoginConfirmIn(Schema):
+ """Either `email` and `code`, as typed on the sign-in page, or the link's `token`."""
+
+ email: str = ""
+ code: str = ""
+ token: str = ""
+
+
+class EmailLoginTokenOut(Schema):
+ access_token: str
+ refresh_token: str
+ token_type: str
+ expires_in: int
+ user: UserSchema
diff --git a/backend/apps/core/templates/email/claim_invite/body.html b/backend/apps/core/templates/email/claim_invite/body.html
index 7dfa056..073d20d 100644
--- a/backend/apps/core/templates/email/claim_invite/body.html
+++ b/backend/apps/core/templates/email/claim_invite/body.html
@@ -147,7 +147,7 @@
-
Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
+
Sign in, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
diff --git a/backend/apps/core/templates/email/claim_invite/body.mjml b/backend/apps/core/templates/email/claim_invite/body.mjml
index 5ecce45..0c2af58 100644
--- a/backend/apps/core/templates/email/claim_invite/body.mjml
+++ b/backend/apps/core/templates/email/claim_invite/body.mjml
@@ -10,7 +10,7 @@
Our team has invited you to maintain the {{ tool_name }} listing on Redaction Tools.Accept the invitation
- Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
+ Sign in, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it.
diff --git a/backend/apps/core/templates/email/claim_invite/body.txt b/backend/apps/core/templates/email/claim_invite/body.txt
index c24fdf3..ef155eb 100644
--- a/backend/apps/core/templates/email/claim_invite/body.txt
+++ b/backend/apps/core/templates/email/claim_invite/body.txt
@@ -3,7 +3,7 @@
Accept here:
{{ url }}
-Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
+Sign in, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it.
diff --git a/backend/apps/core/templates/email/login_link/body.html b/backend/apps/core/templates/email/login_link/body.html
new file mode 100644
index 0000000..f2c1099
--- /dev/null
+++ b/backend/apps/core/templates/email/login_link/body.html
@@ -0,0 +1,212 @@
+
+
+
+
+ Sign in to Redaction Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+