> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nullpass.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Login

> Authenticate user and create session

## Endpoint

```
POST /api/auth/login
```

## Overview

Authenticates a user with email and password. If 2FA is enabled, returns a pending token that requires verification code. Otherwise, returns a full JWT token and user's service access information.

## Request

<ParamField body="email" type="string" required>
  User email address
</ParamField>

<ParamField body="password" type="string" required>
  User password
</ParamField>

<ParamField body="verificationCode" type="string">
  Required if 2FA is enabled. TOTP code from authenticator app.
</ParamField>

## Response (Without 2FA or Valid Code)

<ResponseField name="user" type="object">
  Authenticated user object

  <Expandable title="User Object">
    <ResponseField name="id" type="string">
      User ID
    </ResponseField>

    <ResponseField name="email" type="string">
      User email
    </ResponseField>

    <ResponseField name="displayName" type="string">
      Display name
    </ResponseField>

    <ResponseField name="avatar" type="string">
      Avatar URL (if set)
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="token" type="string">
  JWT token for authentication
</ResponseField>

<ResponseField name="services" type="array">
  Array of user's service entitlements

  <Expandable title="Service Entitlement">
    <ResponseField name="id" type="string">
      Entitlement ID
    </ResponseField>

    <ResponseField name="userId" type="string">
      User ID
    </ResponseField>

    <ResponseField name="service" type="string">
      Service identifier: `DROP`, `MAILS`, `VAULT`, or `DB`
    </ResponseField>

    <ResponseField name="enabled" type="boolean">
      Whether service is enabled
    </ResponseField>

    <ResponseField name="expiresAt" type="string">
      Expiration date (ISO timestamp) or null
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="banned" type="boolean">
  Whether the user account is banned
</ResponseField>

<ResponseField name="disabled" type="boolean">
  Whether the user account is disabled
</ResponseField>

## Response (2FA Required)

<ResponseField name="user" type="object">
  Partial user object (without sensitive data)
</ResponseField>

<ResponseField name="requires2FA" type="boolean">
  Always `true` when 2FA is required
</ResponseField>

<ResponseField name="pendingToken" type="string">
  Temporary token for 2FA verification. Not a full JWT token.
</ResponseField>

<ResponseField name="message" type="string">
  "2FA verification required"
</ResponseField>

## Implementation Details

### Process Flow

1. **CORS & Arcjet**: Validates CORS and applies rate limiting
2. **User Lookup**: Finds user with service access included
3. **Password Verification**: Compares password with bcrypt hash
4. **2FA Check**: If enabled, validates verification code or returns pending token
5. **Session Management**:
   * Reuses existing session if valid and from same IP
   * Creates new session if none exists
   * Updates expiration on existing sessions
6. **Audit Logging**: Logs `USER_LOGIN` and `SESSION_CREATE` (if new session)

### Code Reference

```14:179:nullpass_clean/src/app/api/auth/login/route.ts theme={null}
export async function POST(request: NextRequest) {
  const corsResponse = handleCors(request)
  if (corsResponse) return corsResponse

  const blocked = await protectRoute(request, { requested: 2 })
  if (blocked) return blocked

  try {
    const body = await request.json()
    const validated = loginSchema.parse(body)

    logger.ups('Login attempt:', validated.email)

    const user = await prisma.user.findUnique({
      where: { email: validated.email },
      include: {
        serviceAccess: true,
      },
    })

    if (!user || !user.passwordHash) {
      logger.warn('Login failed: Invalid credentials', validated.email)
      return errorResponse('Invalid credentials', 401, request.headers.get('origin'))
    }

    const isValid = await bcrypt.compare(validated.password, user.passwordHash)
    if (!isValid) {
      logger.warn('Login failed: Invalid password', validated.email)
      return errorResponse('Invalid credentials', 401, request.headers.get('origin'))
    }

    const verificationCode = validated.verificationCode

    if (user.twoFactorEnabled) {
      if (!verificationCode) {
        const pendingToken = generateToken({ userId: user.id, email: user.email })
        return jsonResponse(
          {
            user: {
              id: user.id,
              email: user.email,
              displayName: user.displayName,
              avatar: user.avatar,
            },
            requires2FA: true,
            pendingToken,
            message: '2FA verification required',
          },
          200,
          request.headers.get('origin')
        )
      }

      if (!user.twoFactorSecret) {
        logger.warn('2FA enabled but no secret found', user.id)
        return errorResponse('2FA configuration error', 500, request.headers.get('origin'))
      }

      const verified = speakeasy.totp.verify({
        secret: user.twoFactorSecret,
        encoding: 'base32',
        token: verificationCode,
        window: 2,
      })

      if (!verified) {
        logger.warn('2FA verification failed', user.id)
        return errorResponse('Invalid 2FA verification code', 401, request.headers.get('origin'))
      }
    }

    const clientIp = getClientIp(request)
    const encryptedIp = getClientIpForStorage(request, user.id)

    const existingSession = await prisma.session.findFirst({
      where: {
        userId: user.id,
        ip: encryptedIp,
        expiresAt: {
          gt: new Date(),
        },
      },
      orderBy: {
        createdAt: 'desc',
      },
    })

    let token: string
    const expiresAt = getSessionExpiresAt()

    let sessionCreated = false
    
    if (existingSession) {
      const { verifyToken } = await import('@/lib/auth')
      const tokenPayload = verifyToken(existingSession.token)
      
      if (tokenPayload && tokenPayload.userId === user.id) {
        token = existingSession.token
        await prisma.session.update({
          where: { id: existingSession.id },
          data: {
            expiresAt,
          },
        })
      } else {
        token = generateToken({ userId: user.id, email: user.email })
        await prisma.session.update({
          where: { id: existingSession.id },
          data: {
            token,
            expiresAt,
          },
        })
      }
    } else {
      token = generateToken({ userId: user.id, email: user.email })
      await prisma.session.create({
        data: {
          userId: user.id,
          token,
          expiresAt,
          ip: encryptedIp,
        },
      })
      sessionCreated = true
    }

    await prisma.user.update({
      where: { id: user.id },
      data: { updatedAt: new Date() },
    })

    await createAuditLog(user.id, 'USER_LOGIN', {
      ip: encryptedIp,
      twoFactorUsed: user.twoFactorEnabled && !!verificationCode,
    })
    
    if (sessionCreated) {
      await createAuditLog(user.id, 'SESSION_CREATE', {
        ip: encryptedIp,
      })
    }

    return jsonResponse(
      {
        user: {
          id: user.id,
          email: user.email,
          displayName: user.displayName,
          avatar: user.avatar,
        },
        token,
        services: user.serviceAccess,
      },
      200,
      request.headers.get('origin')
    )
  } catch (error: any) {
    if (error.name === 'ZodError') {
      logger.warn('Login validation error:', error.errors)
      return errorResponse(error.errors[0].message, 400, request.headers.get('origin'))
    }
    logger.error('Login error:', error)
    return errorResponse('Internal server error', 500, request.headers.get('origin'))
  }
}
```

## Status Codes

<ResponseField name="200" type="OK">
  Login successful (with or without 2FA)
</ResponseField>

<ResponseField name="400" type="Bad Request">
  Validation error
</ResponseField>

<ResponseField name="401" type="Unauthorized">
  Invalid credentials or invalid 2FA code
</ResponseField>

<ResponseField name="403" type="Forbidden">
  Blocked by Arcjet
</ResponseField>

<ResponseField name="500" type="Internal Server Error">
  Server error or 2FA configuration error
</ResponseField>

## Example Requests

### Without 2FA

```bash theme={null}
curl -X POST https://auth.nullpass.xyz/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123"
  }'
```

### With 2FA

```bash theme={null}
# Step 1: Initial login (returns requires2FA: true)
curl -X POST https://auth.nullpass.xyz/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123"
  }'

# Step 2: Verify with 2FA code
curl -X POST https://auth.nullpass.xyz/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123",
    "verificationCode": "123456"
  }'
```

## Session Reuse Logic

The endpoint implements smart session reuse:

* If a valid session exists for the user from the same IP, it reuses the token
* Session expiration is updated on reuse
* New sessions are only created when none exist or existing session is invalid

## Audit Events

* **USER\_LOGIN**: Successful login (includes `twoFactorUsed` flag)
* **SESSION\_CREATE**: New session created (only if new session was created)


## OpenAPI

````yaml POST /auth/login
openapi: 3.1.0
info:
  title: Null Pass API
  description: >-
    Internal API documentation for Null Pass authentication and service
    management system
  version: 1.0.0
servers:
  - url: https://auth.nullpass.xyz/api
security: []
paths:
  /auth/login:
    post:
      tags:
        - Authentication
      summary: Login
      description: Authenticate user and create session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LoginResponse'
                  - $ref: '#/components/schemas/Login2FAResponse'
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security: []
components:
  schemas:
    LoginRequest:
      type: object
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
        password:
          type: string
        verificationCode:
          type: string
          description: Required if 2FA is enabled
    LoginResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
        token:
          type: string
        services:
          type: array
          items:
            $ref: '#/components/schemas/ServiceEntitlement'
        banned:
          type: boolean
          description: Whether the user account is banned
        disabled:
          type: boolean
          description: Whether the user account is disabled
    Login2FAResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
        requires2FA:
          type: boolean
          example: true
        pendingToken:
          type: string
        message:
          type: string
          example: 2FA verification required
    Error:
      type: object
      required:
        - error
        - message
      properties:
        error:
          type: string
        message:
          type: string
    User:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        username:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        avatar:
          type: string
          nullable: true
        twoFactorEnabled:
          type: boolean
        createdAt:
          type: string
          format: date-time
    ServiceEntitlement:
      type: object
      properties:
        id:
          type: string
        userId:
          type: string
        service:
          type: string
          enum:
            - DROP
            - MAILS
            - VAULT
            - DB
        tier:
          type: string
          example: premium
        isPremium:
          type: boolean
        accessFlags:
          type: object
          additionalProperties: true
        metadata:
          type: object
          additionalProperties: true
        customStorageLimit:
          type: integer
          nullable: true
        customApiKeyLimit:
          type: integer
          nullable: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

````