SecLab

Authentication

A07CWE-287V6V7
01

What it is

Authentication is the part that answers "is this person who they claim to be". Authentication vulnerabilities are the ways an attacker gets past that step without the right credentials: mass password guessing, skipping a step, reusing leaked credentials, or exploiting the side flows (password reset, "remember me").

02

Why you should care

Relevance: CoreExpected: L2

Authentication is the front door to everything else, so a hole here nullifies all authorisation behind it. And what makes it hard is not the main sign-in step — that is the most-watched part — but the side flows nobody considers "authentication":

  • Password reset. A guessable token, a non-expiring token, or one sent to an address supplied in the request — each is account takeover without knowing the password.
  • User enumeration. Differing error messages, or a timing gap (see the info-disclosure topic), give the attacker a real email list for credential stuffing.
  • "Remember me" and the post-login session. A guessable "remember me" token skips sign-in entirely.

The two largest real-world threats are not logic bugs but scale:

  • Credential stuffing: the attacker tries email+password pairs leaked from another site. People reuse passwords, so a small fraction always hits. It is the most common attack, and MFA is the only control that genuinely stops it.
  • Password spraying: trying one common password (Password1!) across thousands of accounts, so per-account rate limiting does not catch it.

Architectural note: do not build authentication yourself if you can avoid it. An identity provider (Zitadel, Auth0, Entra) already handles MFA, rate limiting, lockout, safe reset, and OIDC (see the oauth-oidc topic). SecLab uses Zitadel for exactly this reason (design/12).

03

How the attack works

There is no single mechanism; there is a surface of a main flow and side flows, and the side flows are where the vulnerabilities live.

Diagram source
flowchart TD    A[Attacker] --> M["Main sign-in<br/>credential stuffing, spraying"]    A --> R["Password reset<br/>guessable / non-expiring token"]    A --> E["User enumeration<br/>differing errors / timing"]    A --> S["Post-login session<br/>guessable remember-me, fixation"]    M --> T[Account takeover]    R --> T    E --> M    S --> T

The key point: user enumeration is step ONE of the others. It does little harm alone, but it turns credential stuffing from "blind guessing" into "attacking a confirmed list".

A table of flows and their characteristic flaws:

FlowFlawFix belongs to
Sign-incredential stuffing, sprayingMFA + rate limit + lockout
Password resetweak token, sent to a request-supplied addressa CSPRNG token, expiry, sent to the STORED address
Enumerationdiffering errors, timingidentical responses + constant time (the info-disclosure topic)
Sessionfixation, guessable remember-me tokenregenerate the session id after sign-in, a CSPRNG token
Password storageweak hash, no saltArgon2id (the password-storage topic)

The last row points to the password-storage topic: how you store passwords is its own topic, large enough to stand alone, but an inseparable part of authentication.

Diagram description: The diagram shows the attacker has four paths to account takeover: the main sign-in via credential stuffing and password spraying; password reset via a guessable or non-expiring token; user enumeration via differing error messages or timing; and the post-login session via a guessable remember-me token or session fixation. User enumeration does not lead directly to takeover but feeds the main sign-in, because it gives the attacker a confirmed email list.

04

Concrete example

Three flows, three flaws — and password reset is the one nobody looks at.

HTTP
# ① Credential stuffing. No logic bug at all — just scale.POST /api/login  {"email":"alice@acme.com","password":"<password leaked from another site>"}# Repeat with 10 million email/password pairs from another breach. A small fraction always hits# because people reuse passwords. MFA is the ONLY thing that stops it.
HTTP
# ② Password reset sent to an address IN THE REQUEST.POST /api/password-reset  {"email":"victim@acme.com","deliverTo":"attacker@evil.example"}# The server sends the reset link to deliverTo → account takeover without the password.# The fix: send to the account's STORED address, not one the client supplies.
HTTP
# ③ A guessable / non-expiring reset token.GET /reset?token=1042-1698765432   ← userId + timestamp, not random# Guessable from the userId and time. The token must be 32 CSPRNG bytes, hashed in the DB,# expiring after 15 minutes, single-use.
HTTP
# After the fix: identical sign-in responses for every failure (the info-disclosure topic).POST /api/login  {"email":"no-such-user@acme.com","password":"x"}HTTP/1.1 401  {"error":"invalid_credentials"}   ← identical for existing and non-existing emails
C#A guessable reset token, sent to a request address, and no rate limiting.
[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req){    var user = await _users.FindByEmailAsync(req.Email);    if (user is null)        // ❌ Rò sự tồn tại: email không có tài khoản trả về khác với email có.        return NotFound(new { error = "No account with that email" });     // ❌ Token đoán được: userId + timestamp. Kẻ tấn công biết userId (từ URL công    //    khai) và đoán được timestamp trong một khoảng hẹp → dựng lại được token.    var token = $"{user.Id}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";     // ❌ Lưu token THÔ, và không hết hạn. Một vụ rò DB là mọi token đặt lại.    user.ResetToken = token;    await _users.SaveAsync(user);     // ❌ Gửi tới địa chỉ TRONG REQUEST, không tới địa chỉ đã lưu → account takeover.    await _mail.SendAsync(req.DeliverTo ?? req.Email, $"Reset: /reset?token={token}");     return Ok();} [HttpPost("/api/login")]public async Task<IActionResult> Login([FromBody] LoginRequest req){    var user = await _users.FindByEmailAsync(req.Email);     // ❌ Không rate limit, không MFA, và trả về sớm khi không có user (chênh thời    //    gian → liệt kê). Credential stuffing chạy tự do ở đây.    if (user is null) return Unauthorized(new { error = "No such user" });    if (!_hasher.Verify(user.PasswordHash, req.Password))        return Unauthorized(new { error = "Wrong password" });     return Ok(new { token = IssueSession(user) });   // session id không đổi → fixation}
05

What happened in the wild

Credential stuffing at industrial scale (2018–present). Reports from Akamai and CDN providers record tens of billions of credential-stuffing attempts per year against retail, finance and streaming. Not a single incident — it is a background threat, and the reason MFA moved from "nice to have" to "required". The root cause is password reuse, so no single app can "fix" it; only MFA makes a leaked password useless.

Account takeovers via password reset (many bug bounty reports). The recurring pattern: the reset token is a sequential number or md5(email+timestamp), or it is sent to an address in the request, or it never expires. Memorable because the reset flow is usually written fast, lightly reviewed, and considered by no one as an authentication surface — exactly block 2's argument.

NIST SP 800-63B (2017, updated 2024) changed password guidance fundamentally: dropping periodic rotation, dropping "must contain a special character", replacing them with checking passwords against a breached-list and allowing long passwords. This is the standard saying that many traditional "strong password rules" actually reduce security.

06

How to defend

Layer 1

MFA — the only control that stops credential stuffing

mandatory

Credential stuffing exploits leaked passwords, so no password check stops it — the password is correct. MFA is the only control that makes a leaked password useless.

  • Prefer passkeys/WebAuthn (see the mfa-passkeys topic): they resist phishing, unlike TOTP and SMS. This is the strongest form of MFA and is becoming the default.
  • TOTP (an authenticator app) is the acceptable minimum. Avoid SMS where possible — it is SIM-swappable, though SMS still beats no MFA.
  • Require MFA for privileged accounts (admin, finance), and strongly encourage it for everyone.

And do not build it yourself: an identity provider handles MFA correctly, with recovery, trusted devices, and step-up for sensitive actions. Building MFA yourself takes on a long list of edge cases (recovery codes, lost devices, TOTP time sync) each of which is a potential vulnerability.

C# · Layer 1A CSPRNG token hashed in the DB, sent to the stored address, identical responses, a new session.
[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req, CancellationToken ct){    var user = await _users.FindByEmailAsync(req.Email, ct);     // Response GIỐNG HỆT dù email có tồn tại hay không — nếu không, luồng đặt lại    // thành một API liệt kê người dùng (khối 3).    if (user is not null)    {        // Token 32 byte CSPRNG. KHÔNG phải userId+timestamp — không đoán được.        var raw = RandomNumberGenerator.GetBytes(32);        var token = WebEncoders.Base64UrlEncode(raw);         // Lưu HASH của token, không phải token thô: nó là credential, đối xử như        // mật khẩu. Một vụ rò DB không cho kẻ tấn công token dùng được.        user.SetResetToken(            SHA256.HashData(raw),            expiresAt: DateTime.UtcNow.AddMinutes(15),   // hết hạn            now: DateTime.UtcNow);        await _users.SaveAsync(user, ct);         // Gửi tới địa chỉ ĐÃ LƯU của tài khoản. req.DeliverTo bị BỎ QUA hoàn toàn —        // đó là đường account takeover ở ví dụ lỗi.        await _mail.SendAsync(user.Email, $"Reset: /reset?token={token}", ct);    }     // Cùng một câu, cùng một status, cho mọi trường hợp.    return Ok(new { message = "If that email has an account, we sent a reset link." });} [HttpPost("/api/login")]public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct){    // Rate limit theo TÀI KHOẢN và theo IP (lớp 2) — hai chiều chặn hai tấn công:    // theo tài khoản chặn brute force, theo IP chặn password spraying.    await _limiter.CheckAsync(account: req.Email, ip: HttpContext.Connection.RemoteIpAddress, ct);     var user = await _users.FindByEmailAsync(req.Email, ct);     // Hằng thời gian: chạy hash giả khi không có user (topic info-disclosure lớp 1b).    var hash = user?.PasswordHash ?? PasswordHasher.DummyHash;    var ok = _hasher.Verify(hash, req.Password);     // Response GIỐNG HỆT cho mọi lỗi — không nói "sai mật khẩu" vs "không có user".    if (user is null || !ok)    {        _log.LogInformation("Failed sign-in for {Email}", req.Email);   // lớp 3        return Unauthorized(new { error = "invalid_credentials" });    }     // MFA: mật khẩu đúng KHÔNG đủ cho tài khoản có quyền cao. Credential stuffing    // dùng mật khẩu đúng, nên đây là chỗ nó bị chặn.    if (user.MfaEnabled || user.Role >= SystemRole.Editor)        return Ok(new { mfaRequired = true, mfaTicket = IssueMfaTicket(user) });     // Sinh session id MỚI sau đăng nhập thành công (chống fixation).    await HttpContext.RegenerateSessionAsync();    return Ok(new { token = IssueSession(user) });}
Layer 1b

A safe password-reset flow — CSPRNG token, expiring, sent to the stored address

mandatory

Block 2 argues password reset is the most-neglected authentication surface. Four rules, and dropping any one is account takeover without a password:

  1. The token is 32 bytes from a CSPRNG, not a userId, timestamp, or md5(email). Store the token's hash in the DB (not the raw token — it is a credential, treat it like a password).
  2. Expire after ~15 minutes and single-use. A non-expiring token is a permanent password.
  3. Send to the account's STORED address, not one in the request. Example ② in block 4 is exactly this bug.
  4. Identical responses whether or not the email exists — "if the email exists, we sent a link". Otherwise the reset flow becomes a user-enumeration API.

And after a reset: invalidate all open sessions for that account — if the account was compromised, a reset must push the attacker out.

Layer 1c

Reveal no account existence — and a safe session after sign-in

mandatory

User enumeration (block 3 calls it step one of the others): identical responses and timing at sign-in, registration and reset. See info-disclosure layer 1b for the constant-time part (running a dummy hash on the no-user branch).

The session after sign-in:

  • Generate a NEW session id immediately after a successful sign-in (against session fixation): if the id does not change, the attacker pre-sets an id for the victim and reuses it after the victim signs in.
  • The "remember me" token is CSPRNG, hashed in the DB, revocable — it is a long-lived credential, so it needs every control a password does.
  • The session cookie is HttpOnly + Secure + SameSite (see the csrf topic).

And store passwords with Argon2id — that is the password-storage topic, but an inseparable part of authentication: a weak hash means a DB breach becomes millions of cleartext passwords.

Layer 2

Rate limit per account AND per IP, plus a breached-password check

mandatory

This layer slows the two scale threats from block 2. The key point is that two different rate-limit dimensions stop two different attacks:

  • Per account stops brute-forcing one account: a soft lock after N failures (increasing delay, or a CAPTCHA), not a hard lock — a hard per-account lock is a DoS (the attacker locks other people's accounts).
  • Per IP / per network stops password spraying: one password tried across thousands of accounts comes from few IPs. Per-account limiting is blind to it because each account sees only one attempt.
  • Check passwords against a breached list (the Have I Been Pwned k-anonymity API) at registration and change. This is the control NIST SP 800-63B recommends instead of complexity rules — it stops credential stuffing at the source by refusing passwords already in a breach list.

And alert on rate: many failed sign-ins across many accounts from one IP range is the signature of spraying (see the rate-limiting topic).

Layer 3

Detection: anomalous sign-ins and notifying the user

Credential stuffing cannot be fully blocked, so detection is part of the defence.

  • Failure/success ratio per IP and per account. An IP with thousands of failures and a few successes is credential stuffing in progress — and those few successes are the accounts already compromised.
  • A successful sign-in from a new location/device → email the user, and for sensitive actions require step-up MFA. The user is the best detector for their own account.
  • A password change, email change, or added MFA → always notify the OLD address. If it is an attacker, the real user learns immediately.

And one preventive control worth more than detection: compare registered emails against a breach list and proactively force a password change when a user's credential appears in a new breach.

07

Verifying the fix

1. Test the password-reset flow — the four rules in layer 1b, one test each:

Shell
B=https://staging.example.com# Sending to a request address must be ignored — the link goes to the STORED address.curl -s -X POST "$B/api/password-reset" -d '{"email":"victim@acme.com","deliverTo":"evil@x.com"}'# Check evil@x.com's mailbox receives NOTHING; victim@acme.com receives the link.

2. Test the reset token is random and expiring — see the csharp / test tab. Assert the token has high entropy (not guessable from userId/time), expires, and is single-use.

3. Check sign-in responses are identical (anti-enumeration) — the same check as the info-disclosure topic: compare content and median timing between existing and non-existing emails.

4. Check the session id CHANGES after sign-in (anti-fixation):

Shell
# The pre-login id must DIFFER from the post-login id.pre=$(curl -si "$B/login" | grep -oi 'session=[^;]*' | head -1)post=$(curl -si -X POST "$B/api/login" -b "$pre" -d '{...}' | grep -oi 'session=[^;]*' | head -1)[ "$pre" != "$post" ] || { echo "session id unchanged after sign-in — fixation"; exit 1; }

5. Check MFA is enforced for admin accounts:

Shell
# Admin sign-in with the correct password but NO MFA must NOT grant a full session.curl -s -X POST "$B/api/login" -d '{"email":"admin@acme.com","password":"correct"}' \  | grep -q 'mfa_required' || echo "admin signed in without MFA"

6. Check a new password is rejected if it is in a breach list:

Shell
# "Password1!" is a top breached password. Registering with it must be rejected.curl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/register" \  -d '{"email":"new@x.com","password":"Password1!"}'   # must be 422
C#The reset flow's four rules are four tests — token, expiry, single-use, stored address.
public class AuthTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public AuthTests(ApiFixture fx) => _fx = fx;     /// <summary>    /// Đặt lại phải gửi tới địa chỉ ĐÃ LƯU, không tới địa chỉ trong request. Đây là    /// một account takeover đầy đủ nếu sai, và luồng reset là bề mặt ít review nhất.    /// </summary>    [Fact]    public async Task Reset_ignores_a_request_supplied_delivery_address()    {        var victim = await _fx.SeedUserAsync("victim@acme.com");         await _fx.Client.PostAsJsonAsync("/api/password-reset",            new { email = "victim@acme.com", deliverTo = "attacker@evil.example" });         // Thư đi tới địa chỉ đã lưu, KHÔNG tới deliverTo.        Assert.Equal("victim@acme.com", _fx.LastEmailRecipient);        Assert.NotEqual("attacker@evil.example", _fx.LastEmailRecipient);    }     /// <summary>Token phải ngẫu nhiên — không dựng lại được từ userId và thời gian.</summary>    [Fact]    public async Task Reset_token_is_high_entropy_and_unpredictable()    {        var user = await _fx.SeedUserAsync("a@acme.com");         var tokens = new HashSet<string>();        for (var i = 0; i < 5; i++)        {            await _fx.Client.PostAsJsonAsync("/api/password-reset", new { email = "a@acme.com" });            tokens.Add(_fx.ExtractResetTokenFromLastEmail());        }         Assert.Equal(5, tokens.Count);                       // mỗi lần một token khác        Assert.All(tokens, t => Assert.True(t.Length >= 40)); // 32 byte base64url        // Và không token nào chứa userId hay một timestamp gần đây.        Assert.All(tokens, t => Assert.DoesNotContain(user.Id.ToString(), t));    }     [Fact]    public async Task Reset_token_expires_and_is_single_use()    {        var (user, token) = await _fx.RequestResetAsync("b@acme.com");         // Dùng một lần: lần đầu OK, lần hai với cùng token phải thất bại.        var first = await _fx.Client.PostAsJsonAsync("/api/reset", new { token, password = "NewPassw0rd!xyz" });        var second = await _fx.Client.PostAsJsonAsync("/api/reset", new { token, password = "Another1!xyz" });        first.EnsureSuccessStatusCode();        Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);         // Hết hạn: một token quá 15 phút phải bị từ chối.        var (_, oldToken) = await _fx.RequestResetAsync("b@acme.com");        _fx.AdvanceClock(TimeSpan.FromMinutes(16));        var expired = await _fx.Client.PostAsJsonAsync("/api/reset", new { token = oldToken, password = "Yet1!more" });        Assert.Equal(HttpStatusCode.BadRequest, expired.StatusCode);    }     /// <summary>Đăng nhập không được rò email nào có tài khoản — nội dung VÀ thời gian.</summary>    [Fact]    public async Task Login_does_not_reveal_which_emails_exist()    {        await _fx.SeedUserAsync("real@acme.com", "correct-horse-battery");         var exists = await _fx.Client.PostAsJsonAsync("/api/login",            new { email = "real@acme.com", password = "wrong" });        var missing = await _fx.Client.PostAsJsonAsync("/api/login",            new { email = "no-such@acme.com", password = "wrong" });         Assert.Equal(exists.StatusCode, missing.StatusCode);        Assert.Equal(await exists.Content.ReadAsStringAsync(),                     await missing.Content.ReadAsStringAsync());    }     /// <summary>Session id phải đổi sau đăng nhập (chống fixation).</summary>    [Fact]    public async Task Session_id_changes_after_login()    {        var client = _fx.NewClient();        var before = await _fx.GetSessionIdAsync(client);   // phiên khách trước đăng nhập         await client.PostAsJsonAsync("/api/login",            new { email = "real@acme.com", password = "correct-horse-battery" });        var after = await _fx.GetSessionIdAsync(client);         Assert.NotEqual(before, after);    }     /// <summary>MFA bắt buộc cho tài khoản có quyền cao — mật khẩu đúng vẫn chưa đủ.</summary>    [Fact]    public async Task Admin_login_requires_mfa()    {        await _fx.SeedUserAsync("admin@acme.com", "correct-horse-battery", role: SystemRole.Admin);         var res = await _fx.Client.PostAsJsonAsync("/api/login",            new { email = "admin@acme.com", password = "correct-horse-battery" });         var body = await res.Content.ReadFromJsonAsync<JsonObject>();        Assert.True(body!["mfaRequired"]!.GetValue<bool>());        Assert.Null(body["token"]);   // không cấp phiên đầy đủ trước MFA    }}
08

Common mistakes

The "fix"Why it is wrong
Per-IP rate limiting onlyPassword spraying comes from few IPs but one attempt per account. You also need per-ACCOUNT limiting
Per-account rate limiting onlyMisses spraying, and a hard per-account lock is a DoS (the attacker locks other people's accounts)
Require complex passwords + periodic rotationNIST SP 800-63B (2024) DROPPED both — they make people choose worse passwords. Check a breach list instead
Reset token = md5(email+timestamp)Guessable. The token must be 32 CSPRNG bytes, hashed in the DB
Reset sent to a request-supplied addressAccount takeover without a password. Send to the STORED address
Differing error messages for existing/non-existing emailsA user-enumeration API. Responses and timing must be identical
Keeping the same session id after sign-inSession fixation. Generate a new id after a successful sign-in
"We have strong passwords so we do not need MFA"Credential stuffing uses the CORRECT password. Only MFA makes it useless
Build MFA/reset/sessions yourselfA long list of edge cases, each a hole. Use an identity provider

The scoping mistake, and it is the central one: treating "authentication" as the sign-in step. The sign-in step is the most reviewed; the vulnerabilities live in password reset, user enumeration, "remember me" and the session — the side flows nobody calls authentication.

The architectural mistake: building it yourself. Correct MFA, safe reset, fixation-free sessions, OIDC — each is a topic, and an identity provider has done all of them. Building it yourself takes on responsibility for a surface an entire industry exists to handle.

09

References

Tier 1CWE-287: Improper Authentication · MITRE · CWE · 4.14
Tier 2Authentication · PortSwigger · Web Security Academy
Tier 2Authentication Cheat Sheet · OWASP · Cheat Sheet Series
Tier 2Forgot Password Cheat Sheet · OWASP · Cheat Sheet Series
Tier 3Credential stuffing · OWASP · Community
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…