Passwordless “magic link” login improves user experience and security when implemented correctly. The flow is: user supplies email → server creates a short-lived, single-use token and sends an email containing a link → user clicks link → server validates token and issues a session (JWT cookie or access token). This article shows a safe, practical implementation using ASP.NET Core for the backend and Angular for the frontend.
Summary (what you will get)
Flowchart, workflow, ER and architecture diagrams
Sequence diagram of request → click → validate → login
SQL scripts for tables: Users, MagicLinks, AuthTokens (optional)
ASP.NET Core sample: token generation, storage, email send, validation, single-use enforcement
Angular sample: request form, callback handling, and storing JWT
Security recommendations and production hardening
Flowchart (small header)
User enters email
↓
POST /auth/magic/request
↓
Server: create token (signed + DB record), send email
↓
User clicks link (email) → GET /auth/magic/validate?token=...
↓
Server validates token, marks used, issues JWT / session cookie
↓
User is logged in
Workflow (small header)
User submits email (UI).
Backend validates user exists (optionally create invite).
Backend generates a short-lived token (cryptographically secure), stores token metadata (expiresAt, used=false), and sends an email with the magic URL: https://app.example.com/auth/magic/validate?token=<token>.
User clicks link; Angular app loads callback route, sends token to API validation endpoint or validation can happen server-side on redirect.
Backend verifies token exists, not expired, not used, optionally binds to IP/device, then marks token used and issues authentication (JWT cookie or access token).
Client receives JWT, stores it (cookie or memory/localStorage) and continues.
ER Diagram (small header)
+-----------+ +-----------------+
| Users | 1 0..* | MagicLinks |
+-----------+ +-----------------+
| UserId PK |--------| MagicLinkId PK |
| Email | | Token (varchar) |
| Name | | UserId FK |
+-----------+ | CreatedAt |
| ExpiresAt |
| Used (bit) |
| UsedAt |
| Device (opt) |
+-----------------+
Optional: AuthTokens table to store issued JWT refresh tokens or sessions.
Architecture Diagram (small header)
[Angular SPA] <--HTTPS--> [ASP.NET Core API]
| |
|-- POST /auth/magic/request
| |
|-- GET/POST /auth/magic/validate
| |
(email link) v
Email provider (SMTP / SendGrid)
Sequence Diagram (small header)
User -> Angular: Submit email
Angular -> API: POST /auth/magic/request { email }
API -> DB: Insert MagicLink record
API -> EmailProvider: send email with URL
User -> EmailClient: click URL
Browser -> AngularRoute: /auth/magic/callback?token=...
Angular -> API: POST /auth/magic/validate { token }
API -> DB: Find record; validate; mark used
API -> TokenService: issue JWT (cookie)
API -> Angular: return success + tokens
Angular -> Storage: save token or accept cookie
User sees logged-in UI
Database scripts (SQL Server) (small header)
CREATE TABLE [Users](
[UserId] UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
[Email] NVARCHAR(256) NOT NULL UNIQUE,
[Name] NVARCHAR(200) NULL,
[CreatedOn] DATETIME2 DEFAULT SYSUTCDATETIME()
);
CREATE TABLE [MagicLinks](
[MagicLinkId] UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
[UserId] UNIQUEIDENTIFIER NOT NULL,
[Token] NVARCHAR(512) NOT NULL, -- store short token, e.g. 64-128 chars
[CreatedAt] DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
[ExpiresAt] DATETIME2 NOT NULL,
[Used] BIT NOT NULL DEFAULT 0,
[UsedAt] DATETIME2 NULL,
[IpAddress] NVARCHAR(100) NULL,
[UserAgent] NVARCHAR(500) NULL,
CONSTRAINT FK_MagicLinks_User FOREIGN KEY (UserId) REFERENCES Users(UserId)
);
-- Index for quick lookup
CREATE INDEX IX_MagicLinks_Token ON MagicLinks(Token);
CREATE INDEX IX_MagicLinks_UserId ON MagicLinks(UserId);
Design choices & security principles (small header)
Single-use tokens: mark token as used atomically to prevent replay.
Short lifetime: e.g., 10–15 minutes.
Cryptographically strong tokens: use RNG or sign JWT with server secret.
Store token metadata in DB: to support revocation, auditing, replay prevention.
Rate limiting: per-email and per-IP to avoid spam.
Optional device/IP binding: bind token to device fingerprint to reduce abuse.
Issue long-lived session tokens after validation: JWT + refresh tokens if needed.
Use HTTPS always.
Backend: ASP.NET Core — core implementation (small header)
Below is a concise but complete set of C# classes and controller code. It uses EF Core for DB, IEmailSender for email, and JwtSecurityTokenHandler for issuing JWT.
Note: replace email sending implementation with your provider (SendGrid/SMTP). Keep private keys and secrets in configuration / secret store.
1) Models & DTOs
MagicLinkRequestDto.cs
public class MagicLinkRequestDto
{
public string Email { get; set; } = null!;
}
MagicLinkValidateDto.cs
public class MagicLinkValidateDto
{
public string Token { get; set; } = null!;
// optional device info
public string? DeviceId { get; set; }
}
2) EF Core entities (User and MagicLink)
(Align with DB script)
3) Utility: Token generator
Use cryptographic RNG and HMAC signing for token integrity.
TokenService.cs
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
public class TokenService
{
private readonly IDataProtector _protector;
public TokenService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("MagicLinkProtector.v1");
}
// Create a token that contains a random id + timestamp, protected by data protection
public string CreateToken(Guid magicLinkId)
{
// payload = MagicLinkId|ticks
var payload = $"{magicLinkId:N}|{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
var protectedPayload = _protector.Protect(payload);
return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(protectedPayload));
}
public bool TryUnprotect(string token, out Guid magicLinkId, out DateTimeOffset createdAt)
{
magicLinkId = Guid.Empty; createdAt = default;
try
{
var decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token));
var unprotected = _protector.Unprotect(decoded);
var parts = unprotected.Split('|');
if (parts.Length != 2) return false;
magicLinkId = Guid.Parse(parts[0]);
createdAt = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parts[1]));
return true;
}
catch { return false; }
}
}
IDataProtector is recommended over hand-rolled HMAC; it rotates keys if configured.
4) Email sender interface (abstract)
IEmailSender.cs
public interface IEmailSender
{
Task SendAsync(string toEmail, string subject, string htmlContent);
}
Implement using SMTP or provider.
5) MagicLinkService (create and validate)
MagicLinkService.cs
public class MagicLinkService
{
private readonly AppDbContext _db;
private readonly TokenService _tokenService;
private readonly IEmailSender _email;
private readonly IConfiguration _cfg;
public MagicLinkService(AppDbContext db, TokenService tokenService, IEmailSender email, IConfiguration cfg)
{
_db = db; _tokenService = tokenService; _email = email; _cfg = cfg;
}
public async Task RequestMagicLinkAsync(string email, string ip = null, string userAgent = null)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user == null)
{
// Option A: silently create user (invite) OR return success to avoid account enumeration
// For privacy, we return success and optionally send invite email only.
return;
}
var magic = new MagicLink
{
UserId = user.UserId,
CreatedAt = DateTimeOffset.UtcNow.UtcDateTime,
ExpiresAt = DateTimeOffset.UtcNow.UtcDateTime.AddMinutes(15), // 15 min
Used = false,
IpAddress = ip,
UserAgent = userAgent
};
_db.MagicLinks.Add(magic);
await _db.SaveChangesAsync();
var token = _tokenService.CreateToken(magic.MagicLinkId);
var baseUrl = _cfg["App:BaseUrl"] ?? "https://app.example.com";
var magicUrl = $"{baseUrl}/auth/magic/callback?token={token}";
var html = $"<p>Click to sign in: <a href=\"{magicUrl}\">{magicUrl}</a></p><p>Expires in 15 minutes.</p>";
await _email.SendAsync(user.Email, "Your sign-in link", html);
}
public async Task<(bool Success, string? Error, User? User)> ValidateMagicLinkAsync(string token, string deviceId = null)
{
if (!_tokenService.TryUnprotect(token, out var magicLinkId, out var createdAt))
return (false, "Invalid token", null);
var magic = await _db.MagicLinks.FirstOrDefaultAsync(m => m.MagicLinkId == magicLinkId);
if (magic == null) return (false, "Token not recognized", null);
if (magic.Used) return (false, "Token already used", null);
if (magic.ExpiresAt < DateTimeOffset.UtcNow) return (false, "Token expired", null);
// Optionally verify createdAt vs magic.CreatedAt for tampering (already protected)
// Mark used atomically
magic.Used = true;
magic.UsedAt = DateTimeOffset.UtcNow.UtcDateTime;
await _db.SaveChangesAsync();
var user = await _db.Users.FindAsync(magic.UserId);
return (true, null, user);
}
}
Use transactions if you expect concurrent validation attempts; marking Used must be atomic to prevent race conditions.
6) AuthController endpoints
AuthController.cs
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly MagicLinkService _magic;
private readonly IJwtService _jwt; // service to create JWT tokens
public AuthController(MagicLinkService magic, IJwtService jwt)
{
_magic = magic; _jwt = jwt;
}
[HttpPost("magic/request")]
public async Task<IActionResult> Request([FromBody] MagicLinkRequestDto dto)
{
// Rate limit checks should be applied (per IP/email/day)
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var ua = Request.Headers["User-Agent"].ToString();
await _magic.RequestMagicLinkAsync(dto.Email, ip, ua);
// Always return 200 to avoid account enumeration
return Ok(new { message = "If an account exists we sent a sign-in link." });
}
[HttpPost("magic/validate")]
public async Task<IActionResult> Validate([FromBody] MagicLinkValidateDto dto)
{
var result = await _magic.ValidateMagicLinkAsync(dto.Token, dto.DeviceId);
if (!result.Success) return BadRequest(new { error = result.Error });
var jwt = _jwt.CreateToken(result.User!);
// Option A: return token in response
return Ok(new { token = jwt });
// Option B: set HttpOnly cookie:
// Response.Cookies.Append("access_token", jwt, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict });
// return Ok();
}
}
7) JWT service (simplified)
IJwtService and JwtService create a JWT with standard claims.
public class JwtService : IJwtService
{
private readonly IConfiguration _cfg;
public JwtService(IConfiguration cfg) { _cfg = cfg; }
public string CreateToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new [] {
new Claim(JwtRegisteredClaimNames.Sub, user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim("name", user.Name ?? "")
};
var token = new JwtSecurityToken(
issuer: _cfg["Jwt:Issuer"],
audience: _cfg["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(8),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Frontend Angular — minimal flow (small header)
We show two parts: request form and callback handler.
1) Request form component (HTML & TS)
magic-request.component.html
<form (ngSubmit)="send()">
<input type="email" [(ngModel)]="email" name="email" placeholder="Email" required />
<button type="submit">Send Magic Link</button>
</form>
<p *ngIf="message">{{message}}</p>
magic-request.component.ts
@Component({...})
export class MagicRequestComponent {
email = '';
message?: string;
constructor(private http: HttpClient) {}
send() {
this.http.post('/api/auth/magic/request', { email: this.email }).subscribe(() => {
this.message = 'If an account exists we have sent a sign-in link to your email.';
}, err => this.message = 'Unable to process request.');
}
}
2) Callback route (user clicks link, lands on SPA with token in URL)
magic-callback.component.ts
@Component({...})
export class MagicCallbackComponent implements OnInit {
constructor(private route: ActivatedRoute, private http: HttpClient, private router: Router, private auth: AuthService) {}
ngOnInit() {
this.route.queryParamMap.subscribe(params => {
const token = params.get('token');
if (!token) { this.router.navigate(['/login']); return; }
this.http.post<{ token: string }>('/api/auth/magic/validate', { token })
.subscribe(res => {
// store JWT (or if cookie used no need)
if (res.token) {
localStorage.setItem('access_token', res.token);
this.auth.setToken(res.token);
}
this.router.navigate(['/']);
}, err => {
// show error
this.router.navigate(['/login'], { queryParams: { error: 'invalid_or_expired' }});
});
});
}
}
For security prefer HttpOnly cookie issuance in backend; then the SPA doesn't store JWT in localStorage (reduces XSS risk). If you return JWT, store in memory or use secure cookie.
Rate limiting & anti-abuse (small header)
Limit POST /auth/magic/request per email and per IP (e.g., 5 requests per hour).
Limit validations per token attempts.
Add CAPTCHA for suspicious traffic.
Log email sends and failures.
Audit & analytics (small header)
Store sends and validations in MagicLinks table. For each validation, record IP, user agent and whether validation succeeded. This helps detect abuse.
Extra recommendations (small header)
Use short-lived magic links (10–15 minutes).
Use single-use tokens; set Used = true immediately upon validation (in DB transaction).
Prefer server-set HttpOnly cookies for session tokens; avoids storing JWT in localStorage.
Use Data Protection or signed tokens (avoid plain random tokens if no DB check). If you keep stateless JWT magic tokens, you must still store used token identifiers to prevent reuse.
Add user-friendly UX: “I didn’t get the link” resend flow, and show only generic reponses to avoid user enumeration.
Provide an audit UI to show recent magic link sends and activations to admins.
Production checklist (small header)
Configure strong secrets via Key Vault or environment variables.
Enforce HTTPS and secure cookie flags.
Add monitoring and alerting for high volume requests.
Implement background cleanup job to delete expired unused MagicLinks.
Use transactional DB operations when marking tokens used to avoid race conditions.
Example: full end-to-end notes (small header)
Deploy ASP.NET Core with DataProtection using key ring persisted to disk/Blob/KeyVault.
Frontend uses magic-request and magic-callback routes.
Email provider must support reliable delivery; ensure SPF/DKIM configured.
Consider offering alternative login (social, password) for users without email.
Conclusion
Magic links offer a modern, convenient, and secure way to authenticate users when implemented carefully: short expiry, single use, DB-backed tokens, rate limiting, and secure session issuance. The sample code above gives a production-ready blueprint you can adapt to your app.