Skip to content

EmailVerificationToken ​

One issued email verification, written and spent by IAuthEmailService. A spent row is kept rather than deleted, so a second click on the same link is refused exactly as an expired one is. Requesting a token retires the user's unspent ones of that purpose, so requests made one after another leave a single live token per Purpose; two arriving at once can leave both, since no index enforces the limit.

DANGER

EmailVerificationToken is a database entity. Never return it from an endpoint: it carries the token hash and the surrogate keys, so map it to a DTO that exposes only the fields the client needs. Query it by token hash rather than by the emailed value, which the table never holds; TokenHasher.Hash produces the form TokenHash holds.

Usage ​

csharp
using Microsoft.EntityFrameworkCore;
using AlmightyShogun.AspNet.Auth.Credentials;

public sealed class PendingEmailChangeReader(AppDbContext database)
{
    public async Task<string?> GetPendingAddressAsync(int userId)
    {
        DateTimeOffset now = DateTimeOffset.UtcNow;

        EmailVerificationToken? pending = await database
            .EmailVerificationTokens
            .Where(token => token.UserId == userId && token.UsedAt == null)
            .Where(token => token.Purpose == EmailVerificationPurpose.EmailChange)
            .FirstOrDefaultAsync(token => token.ExpiresAt > now);

        return pending?.Email;
    }
}

Fields

Id: int
The surrogate key. Never leaves the server; the emailed token is the only handle a caller has on this row.

UserId: int
The user the verification was issued for.

TokenHash: string
Hash of the token that was emailed. The emailed value cannot be read back out of the database.

Email: string
The address being verified, which Purpose says how to read. For a registration it repeats the address the user already holds; for a change it is the pending one, written onto the user at redemption.

Purpose: EmailVerificationPurpose
Which flow the token was issued for, one of the EmailVerificationPurpose values. Redemption requires it to match the method presented with the token, so a change link cannot be spent on the registration endpoint.
Default: Registration

CreatedAt: DateTimeOffset
When the verification was requested.

ExpiresAt: DateTimeOffset
When the token stops being usable. Fixed at issue from EmailVerificationMinutes rather than extended on each attempt.

UsedAt: DateTimeOffset?
When the token was spent, or null while it is still usable. Stamped at redemption, when a later request of the same purpose retires it, and, on a registration token, when a change of email is redeemed, whether or not that change moves the account off the address the token names, so the row stays in the table every time.
Default: null

IsActive: bool
Whether the token would still be accepted, meaning unspent and not past its expiry. Neither Purpose nor, for a registration, whether Email is still the account's is part of it, so an active row is not necessarily one the endpoint at hand will take. Computed, not mapped, so it cannot be used in a query.

All packages are released under the MIT License.