Passwordless Authentication with Email Verification and One-Time Codes Annotation 1
Passwords are often one of the weakest parts of an authentication system.
Users reuse them, choose weak combinations, forget them, and sometimes expose them through phishing attacks. Applications then need password-reset flows, password policies, secure hashing, breach monitoring, and several additional protections.
One alternative is passwordless authentication: the user proves ownership of an email address and receives access without creating a password.
However, implementing this securely requires more than sending a link containing an access token. In this project, I built a flow that separates email verification from session creation by using signed verification tokens and short-lived, single-use authorization codes.
Architecture
The solution contains the following components:
- A React Native application built with Expo
- An API Gateway
- A Users microservice
- PostgreSQL
- Debezium
- Kafka
- A Message microservice
- Resend as the email provider
Each component has a specific responsibility.
The Users service owns user identities and sessions. The Message service handles email delivery. The Gateway validates access to the APIs. PostgreSQL stores application data, while Debezium and Kafka connect user creation to asynchronous email delivery.
Step 1: The user enters an email
The flow starts with a simple form in the mobile application.
The user enters an email address, and the application sends:
POST /api/v1/users
Content-Type: application/json
{
"email": "user@example.com"
}This endpoint works for both registration and authentication.
The application does not need to know whether the account already exists:
- If the email is new, the backend creates a user.
- If the email already belongs to an active user, the backend starts a new authentication attempt.
- If the account cannot be used, the response remains intentionally generic.
The API responds with:
202 Accepted{
"message": "If the address can be used, we will send an access link."
}Using the same response helps prevent account enumeration. An attacker should not be able to discover registered users by comparing API responses.
Step 2: The operation is stored transactionally
For a new address, the Users service needs to perform two operations:
- Create the user.
- Request an authentication email.
A dangerous implementation would create the user first and publish a Kafka message afterward.
If the database operation succeeded but Kafka was temporarily unavailable, the system would have a user without an email event. Retrying could also produce duplicated or inconsistent operations.
To avoid this, the system uses the transactional outbox pattern.
Inside a single PostgreSQL transaction, the Users service:
- Creates or locates the user.
- Inserts an event into
users_outbox. - Commits the transaction.
The event is conceptually represented as:
{
"event_type": "user.authentication-requested",
"payload": {
"user_id": "2495b5fa-36ff-40fb-8120-8671e8c85eb2",
"email": "user@example.com"
}
}Because the user and event are committed together, either both operations succeed or neither does.
Step 3: Debezium captures the event
The application does not publish directly to Kafka.
Debezium monitors PostgreSQL’s write-ahead log and captures new records inserted into the outbox table. The Outbox Event Router transforms these records into Kafka messages.
The event is then published to:
users.eventsThis design keeps Kafka availability outside the user-creation transaction while still guaranteeing that committed events can eventually be published.
Step 4: The Message service consumes the event
The Message service subscribes to the Kafka topic using a consumer group.
When it receives an authentication request, it generates a signed email-verification token.
A simplified payload looks like this:
{
"sub": "user-id",
"purpose": "email_verification",
"jti": "unique-token-id",
"iat": 1789148456,
"exp": 1789150256
}The claims have distinct responsibilities:
subidentifies the user.purposeprevents the token from being used for a different operation.jtiuniquely identifies the issued token.iatrecords when it was generated.expdefines its expiration.
The verification token expires after 30 minutes and is signed using HMAC-SHA256.
The Message service creates a URL such as:
https://api.example.com/api/v1/users/email/verify?token=SIGNED_TOKENIt then sends that URL through the configured email provider.
Email delivery happens asynchronously, so the original mobile request does not need to wait for an external email API.
Step 5: The user opens the email link
When the user selects the link, the browser sends:
GET /api/v1/users/email/verify?token=SIGNED_TOKENThis endpoint must be public because the user does not have an authenticated session yet.
The Gateway bypasses access-token authentication for this exact route and forwards the request to the Users service.
Although the endpoint is public, the operation is still protected by the signed verification token.
Step 6: The verification token is validated
The Users service validates:
- The token format
- The cryptographic signature
- The expiration time
- The expected purpose
- The referenced user
A modified or expired token is rejected.
If validation succeeds, the backend marks the user’s email as verified:
email_verified = trueAt this point, the backend knows that the person who opened the link had access to the email inbox.
However, email verification alone does not yet create the application session.
Why not return an access token immediately?
It would be tempting to redirect the user like this:
app://email/verified?token=ACCESS_TOKENThat approach exposes a reusable access token in a URL.
URLs may be recorded in:
- Browser history
- Reverse-proxy logs
- Application logs
- Analytics platforms
- Screenshots
- Clipboard history
- Email-security scanners
- Monitoring systems
An access token can remain valid for a relatively long time. Anyone who obtains it may be able to impersonate the user until it expires.
Instead, the system returns a temporary authorization code.
Step 7: The backend generates a one-time code
After verifying the email, the Users service generates 32 cryptographically random bytes:
const code = randomBytes(32).toString('base64url')The raw code is sent to the application, but it is never stored directly in the database.
Before storage, the backend calculates its SHA-256 hash:
const tokenHash = createHash('sha256')
.update(code)
.digest('hex')The database stores:
email_login_codes
├── token_hash
├── user_id
├── expires_at
├── consumed_at
└── created_atThis code:
- Expires after five minutes.
- Can only be consumed once.
- Is associated with a specific user.
- Is stored only as a hash.
If the database were exposed, the stored hash could not be directly used as the login code.
Step 8: The browser redirects to the mobile application
The backend constructs a deep link:
app://email/verified?code=ONE_TIME_CODEAndroid or iOS recognizes the custom application scheme and opens the installed mobile app.
Expo Router maps the URL to the email-verification screen.
An important detail is that the API Gateway must preserve the Location response header exactly. A proxy that rewrites custom schemes could accidentally transform it into an invalid HTTP route.
The expected response is:
HTTP/1.1 302 Found
Location: app://email/verified?code=ONE_TIME_CODEStep 9: The application exchanges the code
The verification screen extracts the code from the deep link and sends:
POST /api/v1/users/email/session
Content-Type: application/json
{
"code": "ONE_TIME_CODE"
}This is another public endpoint because the session has not been established yet.
The code itself authorizes only this specific exchange. It is not accepted as a normal API access token.
The mobile interface displays a loading state while the exchange occurs. The network request also has a timeout so connectivity failures do not leave the interface loading forever.
Step 10: The code is consumed atomically
The Users service hashes the received code and searches for the matching database record.
It then validates that:
- The code exists.
- It has not expired.
- It has not already been consumed.
- The associated user exists.
- The email is verified.
- The user is active.
The database row is locked during this process.
Conceptually, the transaction behaves like this:
BEGIN;
SELECT *
FROM email_login_codes
WHERE token_hash = ?
FOR UPDATE;
UPDATE email_login_codes
SET consumed_at = NOW()
WHERE token_hash = ?;
COMMIT;The row lock matters because two requests could arrive almost simultaneously.
Without locking, both requests could read consumed_at = null before either updates the record. Both might then create valid sessions.
With the transaction and row lock, only one request can consume the code successfully.
A replay attempt returns:
401 Unauthorized{
"error": "invalid_or_expired_code",
"message": "Code is invalid, expired, or has already been used"
}Step 11: The access token is generated
After successfully consuming the one-time code, the Users service creates the actual access token.
The response contains the token and public user information:
{
"token": "ACCESS_TOKEN",
"user": {
"id": "user-id",
"email": "user@example.com",
"email_verified": true,
"active": true
}
}The access token includes:
- The user identifier
- The expected issuer
- The expected audience
- Its issuance time
- Its expiration time
In this implementation, the access token expires after one hour.
Step 12: The application creates the local session
The React Native application stores the returned token in its authentication context.
The navigation layer uses the token to decide which routes are available:
- No token: public authentication screens
- Valid token: protected application screens
Future API requests include:
Authorization: Bearer ACCESS_TOKENThe API Gateway validates the token before forwarding protected requests to internal services.
Gateway-to-service authentication
The architecture also separates client credentials from internal service credentials.
The Gateway does not forward the user’s original access token directly to internal services. After validating it, the Gateway creates a short-lived internal token for the destination service.
This provides a trust boundary:
Client token
↓
API Gateway validation
↓
Gateway-issued internal token
↓
Users serviceThe internal token identifies the Gateway and, when applicable, the authenticated user.
This prevents clients from directly forging internal identity headers or calling service-only endpoints.
Abuse protection
A passwordless flow can be abused to send large numbers of emails.
To reduce that risk, authentication requests are throttled. If another request for the same user was created during the previous 60 seconds, the API returns the same successful response without producing another email event.
The response remains generic so throttling does not reveal account information.
In production, this should be combined with:
- Per-IP rate limits
- Per-email rate limits
- Global delivery limits
- Monitoring for unusual request patterns
- CAPTCHA or challenge mechanisms when abuse is detected
- Expired-code cleanup
- Email-provider bounce and complaint handling
You can see the design of everything at this link.
What this architecture achieves
This design provides:
- Passwordless registration and login
- Email ownership verification
- Asynchronous email delivery
- Transactional event publication
- Short-lived verification tokens
- One-time session codes
- Replay protection
- Reduced account-enumeration risk
- Separation between public and internal credentials
- Protected application navigation
The most important principle is the separation of responsibilities:
The email link proves access to the inbox, the one-time code authorizes one session exchange, and the access token authenticates future API requests.
By keeping these credentials separate, short-lived, and purpose-specific, the authentication flow becomes easier to reason about and safer to operate.