Authentication
Login with Email + Password
Authenticate an existing account. Returns JWT access + refresh tokens, OR `requiresTwoFactor` with a temp token.
POST /api/v1/auth/login — 🌐 Public · Rate limit: 20 req / hour · Captcha: required
Authenticates with email + password. Two flows:
- Standard → returns
accessToken+expiresIn+ setsbiore_refreshhttpOnly cookie. - 2FA enabled → returns
requiresTwoFactor: true+tempToken. Client must callPOST /auth/login/2fanext.
The refresh token is set as an httpOnly, secure, SameSite=Strict cookie scoped to .bio.re. It is not in the response body — clients should not read it.
Request
Body — LoginDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
email | string (RFC 5322) | ✓ | Trimmed + lowercased | Lookup against User.email |
password | string | ✓ | IsString() | bcrypt-compared against User.passwordHash |
captchaToken | string | — | Validated by CaptchaGuard (admin-managed provider) | Required if captcha enabled |
turnstileToken | string | — | Deprecated alias for captchaToken (1 sprint backwards-compat) | — |
Response
200 OK — standard flow (ApiResponseOf<LoginResponseDto>)
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900
}
}200 OK — 2FA flow (LoginResponseDto with requiresTwoFactor)
{
"success": true,
"data": {
"requiresTwoFactor": true,
"tempToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | Invalid email or missing password |
401 | auth.login.invalid_credentials | Email not found OR password mismatch (deliberately ambiguous) |
401 | auth.login.account_locked | User.lockedUntil in the future (too many failed attempts) |
401 | auth.login.account_suspended | User.status = SUSPENDED |
401 | auth.login.account_deactivated | User.status = DEACTIVATED (use reactivate flow) |
403 | auth.login.email_not_verified | Account exists but User.emailVerified = false |
429 | (throttle) | Rate limit exceeded (20 req/hour) |
Side effects
- Lookup
Userby email. - bcrypt compare against
User.passwordHash. - On success: create
Sessionrecord, generate access + refresh JWTs, set httpOnly cookie. - On failure: increment
User.loginAttempts; lock account (User.lockedUntil) after threshold (configauth.lockout_threshold). - If
User.twoFactorEnabled: skip token issuance, returntempTokenfromChallengetable. - Track device via
device-tracking.service— new device → security alert email. - Audit log:
auth.login.successorauth.login.failure.
Code samples
curl -X POST https://api.bio.re/api/v1/auth/login \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{
"email": "[email protected]",
"password": "SecureP@ss123",
"captchaToken": "<turnstile_token>"
}'type LoginResponse =
| { accessToken: string; expiresIn: number }
| { requiresTwoFactor: true; tempToken: string };
async function login(email: string, password: string, captchaToken?: string): Promise<LoginResponse> {
const res = await fetch('https://api.bio.re/api/v1/auth/login', {
method: 'POST',
credentials: 'include', // accept refresh cookie
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, captchaToken }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Login failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation } from '@tanstack/react-query';
export function useLogin() {
return useMutation({
mutationFn: async (input: { email: string; password: string; captchaToken?: string }) => {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Login failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as LoginResponse;
},
onSuccess: (data) => {
if ('requiresTwoFactor' in data) {
router.push(`/auth/2fa?tempToken=${data.tempToken}`);
} else {
// Store accessToken in memory; refresh cookie is httpOnly
router.push('/dashboard');
}
},
});
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/auth/login" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "password": "SecureP@ss123" }'{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900,
"requiresTwoFactor": true,
"tempToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/auth.controller.ts | 119–142 |
| DTO (request) | apps/api-core/src/modules/auth/dto/index.ts | 74–90 (LoginDto) |
| DTO (response) | apps/api-core/src/modules/auth/dto/response.dto.ts | 17–29 (LoginResponseDto) |
| Service | apps/api-core/src/modules/auth/auth.service.ts | login(), device-tracking.service.ts |
| Prisma models | packages/prisma/prisma/schema.prisma | User, Session, Challenge, Device |