401 Unauthorized vs 403 Forbidden

Fundamentals Security Notes Jan 9, 2026 HTTP
http status-codes authentication authorization security

Definition

401 Unauthorized and 403 Forbidden are two distinct HTTP status codes that are often confused. Understanding the difference is critical for API security and proper authentication/authorization flows.

401 Unauthorized:

  • Meaning: The request requires authentication (user must identify themselves)
  • Reason: Missing, invalid, or expired authentication credentials
  • Solution: Client must provide valid credentials (login, refresh token)
  • Header: Must include WWW-Authenticate header specifying auth method

403 Forbidden:

  • Meaning: The server understood the request but refuses to authorize it
  • Reason: User is authenticated but lacks permission to access the resource
  • Solution: Client cannot fix this; requires permission change on server
  • Header: No WWW-Authenticate header required

Key Distinction:

  • 401 β†’ “I don’t know who you are” (identity problem)
  • 403 β†’ “I know who you are, but you can’t do that” (permission problem)

Example

401 Unauthorized - Missing Token:

GET /api/admin/users HTTP/1.1
Host: api.example.com

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.example.com"
Content-Type: application/json

{
  "error": "Unauthorized",
  "message": "Authentication required. Please provide a valid access token."
}

401 Unauthorized - Expired Token:

GET /api/admin/users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EXPIRED_TOKEN

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Token expired"
Content-Type: application/json

{
  "error": "Unauthorized",
  "message": "Access token has expired. Please refresh your token.",
  "expiredAt": "2026-01-09T09:00:00Z"
}

403 Forbidden - Insufficient Permissions (Regular User Accessing Admin):

GET /api/admin/users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.VALID_TOKEN

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "Forbidden",
  "message": "You do not have permission to access this resource. Admin role required.",
  "requiredRole": "admin",
  "userRole": "user"
}

403 Forbidden - Account Suspended:

POST /api/posts HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.VALID_BUT_SUSPENDED_USER

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "Forbidden",
  "message": "Your account has been suspended. Contact support for assistance.",
  "suspendedAt": "2026-01-08T15:00:00Z",
  "reason": "Terms of Service violation"
}

Code Example

JavaScript (Fetch API):

const fetchAdminUsers = async () => {
  try {
    const response = await fetch('https://api.example.com/admin/users', {
      headers: {
        'Authorization': `Bearer ${localStorage.getItem('token')}`,
        'Accept': 'application/json'
      }
    });
    
    // Handle 401 Unauthorized
    if (response.status === 401) {
      const error = await response.json();
      console.error('401 Unauthorized:', error.message);
      
      // Check if token expired
      if (error.message.includes('expired')) {
        console.log('Token expired, attempting refresh...');
        
        // Try to refresh token
        const refreshed = await refreshAccessToken();
        if (refreshed) {
          // Retry request with new token
          return fetchAdminUsers();
        }
      }
      
      // Redirect to login
      console.log('Redirecting to login...');
      window.location.href = '/login';
      return null;
    }
    
    // Handle 403 Forbidden
    if (response.status === 403) {
      const error = await response.json();
      console.error('403 Forbidden:', error.message);
      
      // Show user-friendly message
      alert('Access Denied: ' + error.message);
      
      // Redirect to dashboard (no point in retrying)
      window.location.href = '/dashboard';
      return null;
    }
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    
    return await response.json();
    
  } catch (error) {
    console.error('Error fetching admin users:', error);
    throw error;
  }
};

const refreshAccessToken = async () => {
  try {
    const refreshToken = localStorage.getItem('refreshToken');
    
    const response = await fetch('https://api.example.com/auth/refresh', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ refreshToken })
    });
    
    if (response.status === 401) {
      // Refresh token also expired
      console.error('Refresh token expired, must re-login');
      return false;
    }
    
    if (response.ok) {
      const data = await response.json();
      localStorage.setItem('token', data.accessToken);
      console.log('Access token refreshed successfully');
      return true;
    }
    
    return false;
    
  } catch (error) {
    console.error('Error refreshing token:', error);
    return false;
  }
};

Python (requests library):

import requests

def fetch_admin_users():
    try:
        token = get_token_from_storage()
        
        response = requests.get(
            'https://api.example.com/admin/users',
            headers={
                'Authorization': f'Bearer {token}',
                'Accept': 'application/json'
            }
        )
        
        # Handle 401 Unauthorized
        if response.status_code == 401:
            error = response.json()
            print(f'401 Unauthorized: {error.get("message")}')
            
            # Check if token expired
            if 'expired' in error.get('message', '').lower():
                print('Token expired, attempting refresh...')
                
                # Try to refresh token
                if refresh_access_token():
                    # Retry request with new token
                    return fetch_admin_users()
            
            # Redirect to login
            print('Authentication required, redirecting to login...')
            return None
        
        # Handle 403 Forbidden
        if response.status_code == 403:
            error = response.json()
            print(f'403 Forbidden: {error.get("message")}')
            
            # Show user-friendly message
            print(f'Access Denied: {error.get("message")}')
            
            # No point in retrying (permission won't change)
            return None
        
        response.raise_for_status()
        
        return response.json()
        
    except requests.exceptions.RequestException as e:
        print(f'Error fetching admin users: {e}')
        raise

def refresh_access_token():
    try:
        refresh_token = get_refresh_token_from_storage()
        
        response = requests.post(
            'https://api.example.com/auth/refresh',
            json={'refreshToken': refresh_token}
        )
        
        if response.status_code == 401:
            # Refresh token also expired
            print('Refresh token expired, must re-login')
            return False
        
        if response.ok:
            data = response.json()
            save_token_to_storage(data['accessToken'])
            print('Access token refreshed successfully')
            return True
        
        return False
        
    except requests.exceptions.RequestException as e:
        print(f'Error refreshing token: {e}')
        return False

Diagram

flowchart TB
    START[API Request] --> HAS_TOKEN{Has Auth Token?}
    
    HAS_TOKEN -->|No| RETURN_401[Return 401
WWW-Authenticate header] HAS_TOKEN -->|Yes| VALIDATE{Token Valid?} VALIDATE -->|Invalid/Expired| RETURN_401_EXPIRED[Return 401
Token expired/invalid] VALIDATE -->|Valid| CHECK_PERM{Has Permission?} CHECK_PERM -->|No| RETURN_403[Return 403
Insufficient permissions] CHECK_PERM -->|Yes| RETURN_200[Return 200
Success] RETURN_401 --> CLIENT_LOGIN[Client: Login/Provide Token] RETURN_401_EXPIRED --> CLIENT_REFRESHClient: [Refresh Token] RETURN_403 --> CLIENT_REQUEST[Client: Request Access
or Give Up] RETURN_200 --> CLIENT_SUCCESS[Client: Process Response] style RETURN_401 fill:#ff6b6b style RETURN_401_EXPIRED fill:#ff6b6b style RETURN_403 fill:#ffa726 style RETURN_200 fill:#66bb6a

Security Notes

SECURITY NOTES
CRITICAL - Always use 401 for authentication failures and 403 for authorization failures. Never swap these codes as it confuses clients and creates security vulnerabilities. Always include WWW-Authenticate header with 401 responses. Never expose internal permission logic in 403 error messages. Log all 401 and 403 responses for security monitoring. Use 403 for rate limiting on authenticated endpoints instead of 401. Never use 404 to hide resources from unauthorized users (security through obscurity). Implement proper token expiration and refresh flows to minimize 401 errors.

Analogy

Think of 401 vs 403 like entering a building:

  • 401 Unauthorized β†’ “Show me your ID badge” (guard at entrance)
  • 403 Forbidden β†’ “Your badge works, but you can’t access the executive floor” (elevator restriction)

You need proper identification (401) before they can even check if you have access (403).

Best Practices

  1. Use 401 for Authentication - Missing, invalid, or expired credentials
  2. Use 403 for Authorization - Valid credentials but insufficient permissions
  3. Include WWW-Authenticate - Always add this header to 401 responses
  4. Provide Clear Messages - Explain why access was denied
  5. Never Swap Codes - Don’t use 401 when you mean 403 or vice versa
  6. Log Security Events - Track 401/403 responses for intrusion detection
  7. Implement Token Refresh - Use refresh tokens to minimize 401 errors
  8. Don’t Hide Resources - Use proper 403/401, not 404 for security

Common Mistakes

  • Using 401 for Authorization - Returning 401 when user lacks permissions (should be 403)
  • Using 403 for Authentication - Returning 403 when token is missing/expired (should be 401)
  • No WWW-Authenticate Header - Not including required header with 401 responses
  • 404 for Security - Hiding resources with 404 instead of proper 401/403
  • Exposing Permission Logic - Including too much detail in 403 error messages
  • No Retry for 401 - Not implementing token refresh flows in clients
  • Retrying 403 - Clients retrying 403 errors (which won’t change without server-side permission update)

Standards & RFCs