13.5. Login
Table of Contents
Understanding Login in Backend Systems
Login is the process where a user proves who they are to your backend so that your system can create an authenticated session or issue tokens. In this chapter you will focus on what is unique about login compared to registration, and how to implement a secure login flow in a typical backend.
You will see examples in a style similar to Python and FastAPI, but the ideas apply to any backend stack.
What Makes Login Different From Registration
Registration creates an account. Login uses an existing account.
During registration, you:
- Check that an email or username is not already used.
- Validate password strength.
- Hash the password and store the hash.
- Possibly send verification emails.
During login, you:
- Verify that a user exists.
- Verify that the provided password matches the stored password hash.
- Decide what to return on success, for example:
- Start a session, or
- Return an access token (for example a JWT).
- Handle incorrect credentials without leaking information.
Never store or compare plain text passwords. Always compare the password using a secure password hashing function against the stored hash.
Typical Login Flow
Here is the high-level login flow you will usually implement:
- Client sends credentials to
POST /login. - Backend validates input structure, for example email format.
- Backend finds the user record from the database by email or username.
- Backend verifies the password using a password hashing library.
- Backend performs additional checks, for example:
- Is email verified?
- Is the account disabled or locked?
- Backend issues:
- A session cookie, or
- An access token (and maybe a refresh token), or
- Both tokens and cookies.
- Backend returns a response that indicates success or failure.
Here is a simple pseudo-code example:
@app.post("/login")
def login(data: LoginRequest):
user = get_user_by_email(data.email)
if not user:
raise InvalidCredentialsError()
if not verify_password(data.password, user.password_hash):
raise InvalidCredentialsError()
if not user.is_active:
raise InactiveUserError()
# For token-based auth:
access_token = create_access_token({"sub": user.id})
return {"access_token": access_token, "token_type": "bearer"}
The details of verify_password, create_access_token, and how tokens work are covered in other chapters, so here you focus on how to use them in the login process.
Designing the Login Endpoint
Choosing the HTTP Method and Path
Login is almost always:
- Method:
POST - Path: For example
/login,/auth/login, or/api/v1/login.
You use POST because you are sending sensitive data that should not appear in URLs. Also, you are creating server-side state, like a session, or returning new tokens.
Request Body vs Query Parameters
Credentials should be sent in the request body, not in query parameters.
Bad:
POST /login?email=alice@example.com&password=secret123 HTTP/1.1Good:
POST /login HTTP/1.1
Content-Type: application/json
{
"email": "alice@example.com",
"password": "secret123"
}Using the body makes it easier to avoid logging credentials accidentally, and many HTTP clients treat bodies more securely than query strings.
Request Model Example
You can define a simple request model like:
class LoginRequest(BaseModel):
email: EmailStr
password: strYou might also accept username instead of email, or both:
class LoginRequest(BaseModel):
username_or_email: str
password: strValidating Login Input
Even for login, validate the input structure before trying to authenticate.
Some examples:
- Ensure email is a valid email format, if you accept email.
- Enforce minimal password length for efficiency; do not attempt to hash obviously invalid values like blank strings.
- Limit field sizes to protect against very large requests.
Example with basic validation:
class LoginRequest(BaseModel):
email: EmailStr
password: constr(min_length=8, max_length=128)If validation fails, you respond with a 400 Bad Request, not with an "invalid credentials" error, because this is not an authentication failure, it is an invalid request format.
Looking Up the User
Once the input is valid, you need to find the user record.
Example:
def authenticate_user(email: str, password: str) -> User | None:
user = db.query(User).filter(User.email == email).first()
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return userYou typically:
- Query by a unique identifier, for example email or username.
- Do not reveal in the error whether the email or username exists.
Do not return different error messages for "user does not exist" and "wrong password". Use a single generic error for both.
Verifying the Password
The verification step uses a password hashing function. From the login perspective, there are only two key operations:
hash_password(plain_password)used at registration or password change.verify_password(plain_password, stored_hash)used at login.
Example pattern:
def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash)You never compare the plain password string directly with anything from the database.
Timing and Security Considerations
An attacker might try to measure how long different responses take. To reduce information leaks:
- Use constant-time comparison functions provided by the password library.
- For non-existent users, perform a fake password verification step so that time is similar to the "user exists" case.
Simplified example:
DUMMY_PASSWORD_HASH = "$2b$12$dummy..."
def authenticate_user(email: str, password: str) -> User | None:
user = db.query(User).filter(User.email == email).first()
if not user:
# Fake verification to match timing.
verify_password(password, DUMMY_PASSWORD_HASH)
return None
if not verify_password(password, user.password_hash):
return None
return userHandling Additional Login Checks
Once the password is correct, you may need to enforce other business rules.
Common checks:
- Account is active:
user.is_activeis true. - Email is verified: if you require verification before login.
- Account is not locked: for example after too many failed attempts.
- User has necessary roles: for special login portals such as an admin panel.
Example:
def login(data: LoginRequest):
user = authenticate_user(data.email, data.password)
if not user:
raise InvalidCredentialsError()
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
if settings.REQUIRE_EMAIL_VERIFIED and not user.is_email_verified:
raise HTTPException(status_code=403, detail="Email not verified")
# passed all checks, issue tokens or sessionWhat to Return on Successful Login
The exact response depends on your chosen authentication method. Common patterns:
1. Session Cookie Based Login
Used heavily in traditional web apps:
- On login, you create a session record in your database or cache, for example in Redis.
- You set a secure, HTTP-only cookie like
session_idthat references that session. - The client sends this cookie with each request.
Example response:
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax
Content-Type: application/json
{
"message": "Logged in successfully"
}From the login chapter viewpoint, your key task is to:
- Create the session.
- Attach the cookie to the response.
- Return a meaningful status code and optional user info.
2. Token Based Login
Often used for APIs and mobile apps:
- On login, you generate an access token that encodes or references the user.
- Often, you also generate a refresh token.
- Client stores the tokens and sends the access token in the
Authorizationheader.
Example response body:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600
}From the login perspective:
- You call some
create_access_tokenfunction with user info. - You return the token(s) to the client.
- You do not store the plain tokens in the database unless you have a specific reason.
3. Hybrid Approach
You might:
- Use cookies for browser based flows.
- Use tokens for API calls.
The login endpoint can vary based on client type, for example /web/login vs /api/login.
Handling Incorrect Logins Securely
How you handle failures is crucial for security.
Status Code
For incorrect credentials use:
- 401 Unauthorized or
- 400 Bad Request for malformed requests.
- 403 Forbidden when access is denied for an otherwise authenticated or known user, for example disabled account.
A common pattern:
raise HTTPException(
status_code=401,
detail="Invalid email or password"
)Do Not Reveal Which Field Was Wrong
You should never reveal:
- Whether the email exists.
- Whether the account is active or not.
- Whether the password is close to correct.
Instead, use a generic message:
- "Invalid email or password"
- "Invalid credentials"
Limit Information in Error Messages
Avoid sending too much information in errors. For example, do not include:
- User IDs.
- Internal database queries.
- Stack traces.
Those details belong in your server logs, not in API responses.
Preventing Brute Force Attacks at Login
Login endpoints are a common target for attackers who try many passwords.
You can reduce risk by:
- Rate limiting: limit the number of login attempts per IP or per user.
- Progressive delays: add small delays after repeated failures for a specific user or IP.
- Temporary account lock: lock the account after several failed attempts.
Example logic:
def login(data: LoginRequest):
if too_many_attempts(data.email, client_ip):
raise HTTPException(status_code=429, detail="Too many login attempts")
user = authenticate_user(data.email, data.password)
if not user:
record_failed_login(data.email, client_ip)
raise HTTPException(status_code=401, detail="Invalid email or password")
clear_failed_logins(data.email, client_ip)
# continue with success flowDetails of rate limiting and tracking failed attempts are covered in other security related chapters, but you should keep the pattern in mind as part of login design.
Example End to End Login Endpoint
Here is a more complete example that puts the pieces together for a token based login:
class LoginRequest(BaseModel):
email: EmailStr
password: constr(min_length=8, max_length=128)
class LoginResponse(BaseModel):
access_token: str
token_type: str
expires_in: int
@app.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, request: Request):
client_ip = request.client.host
# 1. Rate limiting
if too_many_attempts(data.email, client_ip):
raise HTTPException(status_code=429, detail="Too many login attempts")
# 2. Authenticate user
user = authenticate_user(data.email, data.password)
if not user:
record_failed_login(data.email, client_ip)
raise HTTPException(status_code=401, detail="Invalid email or password")
# 3. Additional checks
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
# 4. Clear failed attempts on success
clear_failed_logins(data.email, client_ip)
# 5. Create access token
expires_in = settings.ACCESS_TOKEN_EXPIRE_SECONDS
access_token = create_access_token(
{"sub": str(user.id)},
expires_delta=timedelta(seconds=expires_in),
)
# 6. Return response
return LoginResponse(
access_token=access_token,
token_type="bearer",
expires_in=expires_in,
)This example shows how login coordinates:
- Input validation.
- User lookup.
- Password verification.
- Security checks such as rate limiting.
- Token issuance.
Testing the Login Endpoint
You should test login carefully, because small mistakes can open serious vulnerabilities.
Create tests for:
| Scenario | Expected Outcome |
|---|---|
| Correct email and password | 200 OK, valid token or session |
| Wrong email | 401, generic error, no detail about unknown email |
| Correct email, wrong password | 401, same generic error |
| Inactive or disabled user | 403, clear but non-revealing message |
| Too many failed attempts | 429, "Too many login attempts" |
| Invalid email format | 400, validation error |
| Very short password input | 400, validation error if you enforce min length |
Example unit style test in Python:
def test_login_success(client, user_factory):
user = user_factory(email="user@example.com", password="StrongPass123!")
response = client.post("/login", json={
"email": "user@example.com",
"password": "StrongPass123!"
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"Summary
In login you:
- Accept credentials via a secure
POSTendpoint. - Validate input without leaking information.
- Look up the user and verify the password using secure hashing.
- Apply additional business and security checks.
- On success, issue a session or tokens.
- On failure, return generic errors and limit information exposure.
- Consider rate limiting and account lock mechanisms to protect against brute force attacks.
Registration, password hashing details, tokens, sessions, and security are explained in their own dedicated chapters, but you now know how all of them come together in the specific case of user login.
Views: 14
KAHIBARO