diff --git a/i18n/en.json b/i18n/en.json
index 80381dcff9..6d683dfc55 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,4 +1,16 @@
 {
+  "user_pin_code_settings": "PIN Code",
+  "user_pin_code_settings_description": "Manage your PIN code",
+  "current_pin_code": "Current PIN code",
+  "new_pin_code": "New PIN code",
+  "setup_pin_code": "Setup a PIN code",
+  "confirm_new_pin_code": "Confirm new PIN code",
+  "unable_to_change_pin_code": "Unable to change PIN code",
+  "unable_to_setup_pin_code": "Unable to setup PIN code",
+  "pin_code_changed_successfully": "Successfully changed PIN code",
+  "pin_code_setup_successfully": "Successfully setup a PIN code",
+  "pin_code_reset_successfully": "Successfully reset PIN code",
+  "reset_pin_code": "Reset PIN code",
   "about": "About",
   "account": "Account",
   "account_settings": "Account Settings",
@@ -53,6 +65,7 @@
     "confirm_email_below": "To confirm, type \"{email}\" below",
     "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
     "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
+    "confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
     "create_job": "Create job",
     "cron_expression": "Cron expression",
     "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@@ -922,6 +935,7 @@
     "unable_to_remove_reaction": "Unable to remove reaction",
     "unable_to_repair_items": "Unable to repair items",
     "unable_to_reset_password": "Unable to reset password",
+    "unable_to_reset_pin_code": "Unable to reset PIN code",
     "unable_to_resolve_duplicate": "Unable to resolve duplicate",
     "unable_to_restore_assets": "Unable to restore assets",
     "unable_to_restore_trash": "Unable to restore trash",
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 5395f46801..a141d465d1 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -109,8 +109,12 @@ Class | Method | HTTP request | Description
 *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | 
 *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | 
 *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
+*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | 
+*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | 
 *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | 
 *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | 
+*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | 
+*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | 
 *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | 
@@ -304,6 +308,7 @@ Class | Method | HTTP request | Description
  - [AssetTypeEnum](doc//AssetTypeEnum.md)
  - [AssetVisibility](doc//AssetVisibility.md)
  - [AudioCodec](doc//AudioCodec.md)
+ - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
  - [AvatarUpdate](doc//AvatarUpdate.md)
  - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
  - [BulkIdsDto](doc//BulkIdsDto.md)
@@ -383,6 +388,8 @@ Class | Method | HTTP request | Description
  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)
  - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
+ - [PinCodeChangeDto](doc//PinCodeChangeDto.md)
+ - [PinCodeSetupDto](doc//PinCodeSetupDto.md)
  - [PlacesResponseDto](doc//PlacesResponseDto.md)
  - [PurchaseResponse](doc//PurchaseResponse.md)
  - [PurchaseUpdate](doc//PurchaseUpdate.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 9a806a3f20..b2cbe222e8 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -108,6 +108,7 @@ part 'model/asset_stats_response_dto.dart';
 part 'model/asset_type_enum.dart';
 part 'model/asset_visibility.dart';
 part 'model/audio_codec.dart';
+part 'model/auth_status_response_dto.dart';
 part 'model/avatar_update.dart';
 part 'model/bulk_id_response_dto.dart';
 part 'model/bulk_ids_dto.dart';
@@ -187,6 +188,8 @@ part 'model/person_response_dto.dart';
 part 'model/person_statistics_response_dto.dart';
 part 'model/person_update_dto.dart';
 part 'model/person_with_faces_response_dto.dart';
+part 'model/pin_code_change_dto.dart';
+part 'model/pin_code_setup_dto.dart';
 part 'model/places_response_dto.dart';
 part 'model/purchase_response.dart';
 part 'model/purchase_update.dart';
diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart
index bf987f441e..f850bdf403 100644
--- a/mobile/openapi/lib/api/authentication_api.dart
+++ b/mobile/openapi/lib/api/authentication_api.dart
@@ -63,6 +63,86 @@ class AuthenticationApi {
     return null;
   }
 
+  /// Performs an HTTP 'PUT /auth/pin-code' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [PinCodeChangeDto] pinCodeChangeDto (required):
+  Future<Response> changePinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/auth/pin-code';
+
+    // ignore: prefer_final_locals
+    Object? postBody = pinCodeChangeDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [PinCodeChangeDto] pinCodeChangeDto (required):
+  Future<void> changePinCode(PinCodeChangeDto pinCodeChangeDto,) async {
+    final response = await changePinCodeWithHttpInfo(pinCodeChangeDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'GET /auth/status' operation and returns the [Response].
+  Future<Response> getAuthStatusWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/auth/status';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<AuthStatusResponseDto?> getAuthStatus() async {
+    final response = await getAuthStatusWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuthStatusResponseDto',) as AuthStatusResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
   /// Parameters:
   ///
@@ -151,6 +231,84 @@ class AuthenticationApi {
     return null;
   }
 
+  /// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [PinCodeChangeDto] pinCodeChangeDto (required):
+  Future<Response> resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/auth/pin-code';
+
+    // ignore: prefer_final_locals
+    Object? postBody = pinCodeChangeDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [PinCodeChangeDto] pinCodeChangeDto (required):
+  Future<void> resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async {
+    final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'POST /auth/pin-code' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
+  Future<Response> setupPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/auth/pin-code';
+
+    // ignore: prefer_final_locals
+    Object? postBody = pinCodeSetupDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [PinCodeSetupDto] pinCodeSetupDto (required):
+  Future<void> setupPinCode(PinCodeSetupDto pinCodeSetupDto,) async {
+    final response = await setupPinCodeWithHttpInfo(pinCodeSetupDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response].
   /// Parameters:
   ///
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 041a584296..cdd69307ad 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -272,6 +272,8 @@ class ApiClient {
           return AssetVisibilityTypeTransformer().decode(value);
         case 'AudioCodec':
           return AudioCodecTypeTransformer().decode(value);
+        case 'AuthStatusResponseDto':
+          return AuthStatusResponseDto.fromJson(value);
         case 'AvatarUpdate':
           return AvatarUpdate.fromJson(value);
         case 'BulkIdResponseDto':
@@ -430,6 +432,10 @@ class ApiClient {
           return PersonUpdateDto.fromJson(value);
         case 'PersonWithFacesResponseDto':
           return PersonWithFacesResponseDto.fromJson(value);
+        case 'PinCodeChangeDto':
+          return PinCodeChangeDto.fromJson(value);
+        case 'PinCodeSetupDto':
+          return PinCodeSetupDto.fromJson(value);
         case 'PlacesResponseDto':
           return PlacesResponseDto.fromJson(value);
         case 'PurchaseResponse':
diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart
new file mode 100644
index 0000000000..203923164f
--- /dev/null
+++ b/mobile/openapi/lib/model/auth_status_response_dto.dart
@@ -0,0 +1,107 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AuthStatusResponseDto {
+  /// Returns a new [AuthStatusResponseDto] instance.
+  AuthStatusResponseDto({
+    required this.password,
+    required this.pinCode,
+  });
+
+  bool password;
+
+  bool pinCode;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto &&
+    other.password == password &&
+    other.pinCode == pinCode;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (password.hashCode) +
+    (pinCode.hashCode);
+
+  @override
+  String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'password'] = this.password;
+      json[r'pinCode'] = this.pinCode;
+    return json;
+  }
+
+  /// Returns a new [AuthStatusResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AuthStatusResponseDto? fromJson(dynamic value) {
+    upgradeDto(value, "AuthStatusResponseDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return AuthStatusResponseDto(
+        password: mapValueOfType<bool>(json, r'password')!,
+        pinCode: mapValueOfType<bool>(json, r'pinCode')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AuthStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AuthStatusResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AuthStatusResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AuthStatusResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AuthStatusResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AuthStatusResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AuthStatusResponseDto-objects as value to a dart map
+  static Map<String, List<AuthStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AuthStatusResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = AuthStatusResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'password',
+    'pinCode',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/pin_code_change_dto.dart b/mobile/openapi/lib/model/pin_code_change_dto.dart
new file mode 100644
index 0000000000..2e9967aa6b
--- /dev/null
+++ b/mobile/openapi/lib/model/pin_code_change_dto.dart
@@ -0,0 +1,133 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class PinCodeChangeDto {
+  /// Returns a new [PinCodeChangeDto] instance.
+  PinCodeChangeDto({
+    required this.newPinCode,
+    this.password,
+    this.pinCode,
+  });
+
+  String newPinCode;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? password;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? pinCode;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is PinCodeChangeDto &&
+    other.newPinCode == newPinCode &&
+    other.password == password &&
+    other.pinCode == pinCode;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (newPinCode.hashCode) +
+    (password == null ? 0 : password!.hashCode) +
+    (pinCode == null ? 0 : pinCode!.hashCode);
+
+  @override
+  String toString() => 'PinCodeChangeDto[newPinCode=$newPinCode, password=$password, pinCode=$pinCode]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'newPinCode'] = this.newPinCode;
+    if (this.password != null) {
+      json[r'password'] = this.password;
+    } else {
+    //  json[r'password'] = null;
+    }
+    if (this.pinCode != null) {
+      json[r'pinCode'] = this.pinCode;
+    } else {
+    //  json[r'pinCode'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [PinCodeChangeDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static PinCodeChangeDto? fromJson(dynamic value) {
+    upgradeDto(value, "PinCodeChangeDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return PinCodeChangeDto(
+        newPinCode: mapValueOfType<String>(json, r'newPinCode')!,
+        password: mapValueOfType<String>(json, r'password'),
+        pinCode: mapValueOfType<String>(json, r'pinCode'),
+      );
+    }
+    return null;
+  }
+
+  static List<PinCodeChangeDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <PinCodeChangeDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = PinCodeChangeDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, PinCodeChangeDto> mapFromJson(dynamic json) {
+    final map = <String, PinCodeChangeDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = PinCodeChangeDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of PinCodeChangeDto-objects as value to a dart map
+  static Map<String, List<PinCodeChangeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<PinCodeChangeDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = PinCodeChangeDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'newPinCode',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/pin_code_setup_dto.dart b/mobile/openapi/lib/model/pin_code_setup_dto.dart
new file mode 100644
index 0000000000..09933790de
--- /dev/null
+++ b/mobile/openapi/lib/model/pin_code_setup_dto.dart
@@ -0,0 +1,99 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class PinCodeSetupDto {
+  /// Returns a new [PinCodeSetupDto] instance.
+  PinCodeSetupDto({
+    required this.pinCode,
+  });
+
+  String pinCode;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is PinCodeSetupDto &&
+    other.pinCode == pinCode;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (pinCode.hashCode);
+
+  @override
+  String toString() => 'PinCodeSetupDto[pinCode=$pinCode]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'pinCode'] = this.pinCode;
+    return json;
+  }
+
+  /// Returns a new [PinCodeSetupDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static PinCodeSetupDto? fromJson(dynamic value) {
+    upgradeDto(value, "PinCodeSetupDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return PinCodeSetupDto(
+        pinCode: mapValueOfType<String>(json, r'pinCode')!,
+      );
+    }
+    return null;
+  }
+
+  static List<PinCodeSetupDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <PinCodeSetupDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = PinCodeSetupDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, PinCodeSetupDto> mapFromJson(dynamic json) {
+    final map = <String, PinCodeSetupDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = PinCodeSetupDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of PinCodeSetupDto-objects as value to a dart map
+  static Map<String, List<PinCodeSetupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<PinCodeSetupDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = PinCodeSetupDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'pinCode',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart
index 951ee8ce84..ee5c006840 100644
--- a/mobile/openapi/lib/model/user_admin_update_dto.dart
+++ b/mobile/openapi/lib/model/user_admin_update_dto.dart
@@ -17,6 +17,7 @@ class UserAdminUpdateDto {
     this.email,
     this.name,
     this.password,
+    this.pinCode,
     this.quotaSizeInBytes,
     this.shouldChangePassword,
     this.storageLabel,
@@ -48,6 +49,8 @@ class UserAdminUpdateDto {
   ///
   String? password;
 
+  String? pinCode;
+
   /// Minimum value: 0
   int? quotaSizeInBytes;
 
@@ -67,6 +70,7 @@ class UserAdminUpdateDto {
     other.email == email &&
     other.name == name &&
     other.password == password &&
+    other.pinCode == pinCode &&
     other.quotaSizeInBytes == quotaSizeInBytes &&
     other.shouldChangePassword == shouldChangePassword &&
     other.storageLabel == storageLabel;
@@ -78,12 +82,13 @@ class UserAdminUpdateDto {
     (email == null ? 0 : email!.hashCode) +
     (name == null ? 0 : name!.hashCode) +
     (password == null ? 0 : password!.hashCode) +
+    (pinCode == null ? 0 : pinCode!.hashCode) +
     (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
     (storageLabel == null ? 0 : storageLabel!.hashCode);
 
   @override
-  String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
+  String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -107,6 +112,11 @@ class UserAdminUpdateDto {
     } else {
     //  json[r'password'] = null;
     }
+    if (this.pinCode != null) {
+      json[r'pinCode'] = this.pinCode;
+    } else {
+    //  json[r'pinCode'] = null;
+    }
     if (this.quotaSizeInBytes != null) {
       json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
     } else {
@@ -138,6 +148,7 @@ class UserAdminUpdateDto {
         email: mapValueOfType<String>(json, r'email'),
         name: mapValueOfType<String>(json, r'name'),
         password: mapValueOfType<String>(json, r'password'),
+        pinCode: mapValueOfType<String>(json, r'pinCode'),
         quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index c4c9e7d193..a98750edaa 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -2294,6 +2294,139 @@
         ]
       }
     },
+    "/auth/pin-code": {
+      "delete": {
+        "operationId": "resetPinCode",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PinCodeChangeDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Authentication"
+        ]
+      },
+      "post": {
+        "operationId": "setupPinCode",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PinCodeSetupDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "201": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Authentication"
+        ]
+      },
+      "put": {
+        "operationId": "changePinCode",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/PinCodeChangeDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Authentication"
+        ]
+      }
+    },
+    "/auth/status": {
+      "get": {
+        "operationId": "getAuthStatus",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/AuthStatusResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Authentication"
+        ]
+      }
+    },
     "/auth/validateToken": {
       "post": {
         "operationId": "validateAccessToken",
@@ -9031,6 +9164,21 @@
         ],
         "type": "string"
       },
+      "AuthStatusResponseDto": {
+        "properties": {
+          "password": {
+            "type": "boolean"
+          },
+          "pinCode": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "password",
+          "pinCode"
+        ],
+        "type": "object"
+      },
       "AvatarUpdate": {
         "properties": {
           "color": {
@@ -10964,6 +11112,37 @@
         ],
         "type": "object"
       },
+      "PinCodeChangeDto": {
+        "properties": {
+          "newPinCode": {
+            "example": "123456",
+            "type": "string"
+          },
+          "password": {
+            "type": "string"
+          },
+          "pinCode": {
+            "example": "123456",
+            "type": "string"
+          }
+        },
+        "required": [
+          "newPinCode"
+        ],
+        "type": "object"
+      },
+      "PinCodeSetupDto": {
+        "properties": {
+          "pinCode": {
+            "example": "123456",
+            "type": "string"
+          }
+        },
+        "required": [
+          "pinCode"
+        ],
+        "type": "object"
+      },
       "PlacesResponseDto": {
         "properties": {
           "admin1name": {
@@ -13958,6 +14137,11 @@
           "password": {
             "type": "string"
           },
+          "pinCode": {
+            "example": "123456",
+            "nullable": true,
+            "type": "string"
+          },
           "quotaSizeInBytes": {
             "format": "int64",
             "minimum": 0,
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index b2abdb0a24..41898e12da 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -123,6 +123,7 @@ export type UserAdminUpdateDto = {
     email?: string;
     name?: string;
     password?: string;
+    pinCode?: string | null;
     quotaSizeInBytes?: number | null;
     shouldChangePassword?: boolean;
     storageLabel?: string | null;
@@ -510,6 +511,18 @@ export type LogoutResponseDto = {
     redirectUri: string;
     successful: boolean;
 };
+export type PinCodeChangeDto = {
+    newPinCode: string;
+    password?: string;
+    pinCode?: string;
+};
+export type PinCodeSetupDto = {
+    pinCode: string;
+};
+export type AuthStatusResponseDto = {
+    password: boolean;
+    pinCode: boolean;
+};
 export type ValidateAccessTokenResponseDto = {
     authStatus: boolean;
 };
@@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) {
         method: "POST"
     }));
 }
+export function resetPinCode({ pinCodeChangeDto }: {
+    pinCodeChangeDto: PinCodeChangeDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
+        ...opts,
+        method: "DELETE",
+        body: pinCodeChangeDto
+    })));
+}
+export function setupPinCode({ pinCodeSetupDto }: {
+    pinCodeSetupDto: PinCodeSetupDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: pinCodeSetupDto
+    })));
+}
+export function changePinCode({ pinCodeChangeDto }: {
+    pinCodeChangeDto: PinCodeChangeDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
+        ...opts,
+        method: "PUT",
+        body: pinCodeChangeDto
+    })));
+}
+export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: AuthStatusResponseDto;
+    }>("/auth/status", {
+        ...opts
+    }));
+}
 export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts
index 0937fc5324..4129b24124 100644
--- a/server/src/controllers/auth.controller.spec.ts
+++ b/server/src/controllers/auth.controller.spec.ts
@@ -142,4 +142,50 @@ describe(AuthController.name, () => {
       expect(ctx.authenticate).toHaveBeenCalled();
     });
   });
+
+  describe('POST /auth/pin-code', () => {
+    it('should be an authenticated route', async () => {
+      await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
+      expect(ctx.authenticate).toHaveBeenCalled();
+    });
+
+    it('should reject 5 digits', async () => {
+      const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
+      expect(status).toEqual(400);
+      expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
+    });
+
+    it('should reject 7 digits', async () => {
+      const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
+      expect(status).toEqual(400);
+      expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
+    });
+
+    it('should reject non-numbers', async () => {
+      const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
+      expect(status).toEqual(400);
+      expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
+    });
+  });
+
+  describe('PUT /auth/pin-code', () => {
+    it('should be an authenticated route', async () => {
+      await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
+      expect(ctx.authenticate).toHaveBeenCalled();
+    });
+  });
+
+  describe('DELETE /auth/pin-code', () => {
+    it('should be an authenticated route', async () => {
+      await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
+      expect(ctx.authenticate).toHaveBeenCalled();
+    });
+  });
+
+  describe('GET /auth/status', () => {
+    it('should be an authenticated route', async () => {
+      await request(ctx.getHttpServer()).get('/auth/status');
+      expect(ctx.authenticate).toHaveBeenCalled();
+    });
+  });
 });
diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts
index 4ee3c26901..56acaa5c6d 100644
--- a/server/src/controllers/auth.controller.ts
+++ b/server/src/controllers/auth.controller.ts
@@ -1,12 +1,15 @@
-import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
+import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { Request, Response } from 'express';
 import {
   AuthDto,
+  AuthStatusResponseDto,
   ChangePasswordDto,
   LoginCredentialDto,
   LoginResponseDto,
   LogoutResponseDto,
+  PinCodeChangeDto,
+  PinCodeSetupDto,
   SignUpDto,
   ValidateAccessTokenResponseDto,
 } from 'src/dtos/auth.dto';
@@ -74,4 +77,28 @@ export class AuthController {
       ImmichCookie.IS_AUTHENTICATED,
     ]);
   }
+
+  @Get('status')
+  @Authenticated()
+  getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
+    return this.service.getAuthStatus(auth);
+  }
+
+  @Post('pin-code')
+  @Authenticated()
+  setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
+    return this.service.setupPinCode(auth, dto);
+  }
+
+  @Put('pin-code')
+  @Authenticated()
+  async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
+    return this.service.changePinCode(auth, dto);
+  }
+
+  @Delete('pin-code')
+  @Authenticated()
+  async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
+    return this.service.resetPinCode(auth, dto);
+  }
 }
diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts
index a1978d39dd..cc05d2d860 100644
--- a/server/src/dtos/auth.dto.ts
+++ b/server/src/dtos/auth.dto.ts
@@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
 import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
 import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
 import { ImmichCookie } from 'src/enum';
-import { Optional, toEmail } from 'src/validation';
+import { Optional, PinCode, toEmail } from 'src/validation';
 
 export type CookieResponse = {
   isSecure: boolean;
@@ -78,6 +78,26 @@ export class ChangePasswordDto {
   newPassword!: string;
 }
 
+export class PinCodeSetupDto {
+  @PinCode()
+  pinCode!: string;
+}
+
+export class PinCodeResetDto {
+  @PinCode({ optional: true })
+  pinCode?: string;
+
+  @Optional()
+  @IsString()
+  @IsNotEmpty()
+  password?: string;
+}
+
+export class PinCodeChangeDto extends PinCodeResetDto {
+  @PinCode()
+  newPinCode!: string;
+}
+
 export class ValidateAccessTokenResponseDto {
   authStatus!: boolean;
 }
@@ -114,3 +134,8 @@ export class OAuthConfigDto {
 export class OAuthAuthorizeResponseDto {
   url!: string;
 }
+
+export class AuthStatusResponseDto {
+  pinCode!: boolean;
+  password!: boolean;
+}
diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts
index 31275f9c28..9efb531bc7 100644
--- a/server/src/dtos/user.dto.ts
+++ b/server/src/dtos/user.dto.ts
@@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
 import { User, UserAdmin } from 'src/database';
 import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
 import { UserMetadataItem } from 'src/types';
-import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
+import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
 
 export class UserUpdateMeDto {
   @Optional()
@@ -116,6 +116,9 @@ export class UserAdminUpdateDto {
   @IsString()
   password?: string;
 
+  @PinCode({ optional: true, nullable: true, emptyToNull: true })
+  pinCode?: string | null;
+
   @Optional()
   @IsString()
   @IsNotEmpty()
diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql
index 72881feea7..33f2960266 100644
--- a/server/src/queries/user.repository.sql
+++ b/server/src/queries/user.repository.sql
@@ -87,6 +87,16 @@ where
   "users"."isAdmin" = $1
   and "users"."deletedAt" is null
 
+-- UserRepository.getForPinCode
+select
+  "users"."pinCode",
+  "users"."password"
+from
+  "users"
+where
+  "users"."id" = $1
+  and "users"."deletedAt" is null
+
 -- UserRepository.getByEmail
 select
   "id",
diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts
index 4d7671ca92..f8710746aa 100644
--- a/server/src/repositories/user.repository.ts
+++ b/server/src/repositories/user.repository.ts
@@ -89,13 +89,23 @@ export class UserRepository {
     return !!admin;
   }
 
+  @GenerateSql({ params: [DummyValue.UUID] })
+  getForPinCode(id: string) {
+    return this.db
+      .selectFrom('users')
+      .select(['users.pinCode', 'users.password'])
+      .where('users.id', '=', id)
+      .where('users.deletedAt', 'is', null)
+      .executeTakeFirstOrThrow();
+  }
+
   @GenerateSql({ params: [DummyValue.EMAIL] })
-  getByEmail(email: string, withPassword?: boolean) {
+  getByEmail(email: string, options?: { withPassword?: boolean }) {
     return this.db
       .selectFrom('users')
       .select(columns.userAdmin)
       .select(withMetadata)
-      .$if(!!withPassword, (eb) => eb.select('password'))
+      .$if(!!options?.withPassword, (eb) => eb.select('password'))
       .where('email', '=', email)
       .where('users.deletedAt', 'is', null)
       .executeTakeFirst();
diff --git a/server/src/schema/migrations/1746768490606-AddUserPincode.ts b/server/src/schema/migrations/1746768490606-AddUserPincode.ts
new file mode 100644
index 0000000000..12dc3c2d12
--- /dev/null
+++ b/server/src/schema/migrations/1746768490606-AddUserPincode.ts
@@ -0,0 +1,9 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db);
+}
+
+export async function down(db: Kysely<any>): Promise<void> {
+  await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db);
+}
diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts
index 7525a739a6..c806d6e3f7 100644
--- a/server/src/schema/tables/user.table.ts
+++ b/server/src/schema/tables/user.table.ts
@@ -37,6 +37,9 @@ export class UserTable {
   @Column({ default: '' })
   password!: Generated<string>;
 
+  @Column({ nullable: true })
+  pinCode!: string | null;
+
   @CreateDateColumn()
   createdAt!: Generated<Timestamp>;
 
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index 75f5b8a52d..82172d6b95 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -1,5 +1,6 @@
 import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
 import { DateTime } from 'luxon';
+import { SALT_ROUNDS } from 'src/constants';
 import { UserAdmin } from 'src/database';
 import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
 import { AuthType, Permission } from 'src/enum';
@@ -118,7 +119,7 @@ describe(AuthService.name, () => {
 
       await sut.changePassword(auth, dto);
 
-      expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
+      expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
       expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
     });
 
@@ -859,4 +860,77 @@ describe(AuthService.name, () => {
       expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
     });
   });
+
+  describe('setupPinCode', () => {
+    it('should setup a PIN code', async () => {
+      const user = factory.userAdmin();
+      const auth = factory.auth({ user });
+      const dto = { pinCode: '123456' };
+
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
+      mocks.user.update.mockResolvedValue(user);
+
+      await sut.setupPinCode(auth, dto);
+
+      expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id);
+      expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS);
+      expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) });
+    });
+
+    it('should fail if the user already has a PIN code', async () => {
+      const user = factory.userAdmin();
+      const auth = factory.auth({ user });
+
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
+
+      await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code');
+    });
+  });
+
+  describe('changePinCode', () => {
+    it('should change the PIN code', async () => {
+      const user = factory.userAdmin();
+      const auth = factory.auth({ user });
+      const dto = { pinCode: '123456', newPinCode: '012345' };
+
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
+      mocks.user.update.mockResolvedValue(user);
+      mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
+
+      await sut.changePinCode(auth, dto);
+
+      expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)');
+      expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' });
+    });
+
+    it('should fail if the PIN code does not match', async () => {
+      const user = factory.userAdmin();
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
+      mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
+
+      await expect(
+        sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
+      ).rejects.toThrow('Wrong PIN code');
+    });
+  });
+
+  describe('resetPinCode', () => {
+    it('should reset the PIN code', async () => {
+      const user = factory.userAdmin();
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
+      mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
+
+      await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
+
+      expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
+    });
+
+    it('should throw if the PIN code does not match', async () => {
+      const user = factory.userAdmin();
+      mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
+      mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
+
+      await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
+    });
+  });
 });
diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts
index b250b63a5e..65dd84693b 100644
--- a/server/src/services/auth.service.ts
+++ b/server/src/services/auth.service.ts
@@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core';
 import { UserAdmin } from 'src/database';
 import {
   AuthDto,
+  AuthStatusResponseDto,
   ChangePasswordDto,
   LoginCredentialDto,
   LogoutResponseDto,
   OAuthCallbackDto,
   OAuthConfigDto,
+  PinCodeChangeDto,
+  PinCodeResetDto,
+  PinCodeSetupDto,
   SignUpDto,
   mapLoginResponse,
 } from 'src/dtos/auth.dto';
@@ -56,9 +60,9 @@ export class AuthService extends BaseService {
       throw new UnauthorizedException('Password login has been disabled');
     }
 
-    let user = await this.userRepository.getByEmail(dto.email, true);
+    let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
     if (user) {
-      const isAuthenticated = this.validatePassword(dto.password, user);
+      const isAuthenticated = this.validateSecret(dto.password, user.password);
       if (!isAuthenticated) {
         user = undefined;
       }
@@ -86,12 +90,12 @@ export class AuthService extends BaseService {
 
   async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
     const { password, newPassword } = dto;
-    const user = await this.userRepository.getByEmail(auth.user.email, true);
+    const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
     if (!user) {
       throw new UnauthorizedException();
     }
 
-    const valid = this.validatePassword(password, user);
+    const valid = this.validateSecret(password, user.password);
     if (!valid) {
       throw new BadRequestException('Wrong password');
     }
@@ -103,6 +107,56 @@ export class AuthService extends BaseService {
     return mapUserAdmin(updatedUser);
   }
 
+  async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) {
+    const user = await this.userRepository.getForPinCode(auth.user.id);
+    if (!user) {
+      throw new UnauthorizedException();
+    }
+
+    if (user.pinCode) {
+      throw new BadRequestException('User already has a PIN code');
+    }
+
+    const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS);
+    await this.userRepository.update(auth.user.id, { pinCode: hashed });
+  }
+
+  async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
+    const user = await this.userRepository.getForPinCode(auth.user.id);
+    this.resetPinChecks(user, dto);
+
+    await this.userRepository.update(auth.user.id, { pinCode: null });
+  }
+
+  async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
+    const user = await this.userRepository.getForPinCode(auth.user.id);
+    this.resetPinChecks(user, dto);
+
+    const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
+    await this.userRepository.update(auth.user.id, { pinCode: hashed });
+  }
+
+  private resetPinChecks(
+    user: { pinCode: string | null; password: string | null },
+    dto: { pinCode?: string; password?: string },
+  ) {
+    if (!user.pinCode) {
+      throw new BadRequestException('User does not have a PIN code');
+    }
+
+    if (dto.password) {
+      if (!this.validateSecret(dto.password, user.password)) {
+        throw new BadRequestException('Wrong password');
+      }
+    } else if (dto.pinCode) {
+      if (!this.validateSecret(dto.pinCode, user.pinCode)) {
+        throw new BadRequestException('Wrong PIN code');
+      }
+    } else {
+      throw new BadRequestException('Either password or pinCode is required');
+    }
+  }
+
   async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
     const adminUser = await this.userRepository.getAdmin();
     if (adminUser) {
@@ -371,11 +425,12 @@ export class AuthService extends BaseService {
     throw new UnauthorizedException('Invalid API key');
   }
 
-  private validatePassword(inputPassword: string, user: { password?: string }): boolean {
-    if (!user || !user.password) {
+  private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
+    if (!existingHash) {
       return false;
     }
-    return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
+
+    return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
   }
 
   private async validateSession(tokenValue: string): Promise<AuthDto> {
@@ -428,4 +483,16 @@ export class AuthService extends BaseService {
     }
     return url;
   }
+
+  async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> {
+    const user = await this.userRepository.getForPinCode(auth.user.id);
+    if (!user) {
+      throw new UnauthorizedException();
+    }
+
+    return {
+      pinCode: !!user.pinCode,
+      password: !!user.password,
+    };
+  }
 }
diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts
index c1c6cc49ec..38c0106f4b 100644
--- a/server/src/services/user-admin.service.ts
+++ b/server/src/services/user-admin.service.ts
@@ -70,6 +70,10 @@ export class UserAdminService extends BaseService {
       dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
     }
 
+    if (dto.pinCode) {
+      dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS);
+    }
+
     if (dto.storageLabel === '') {
       dto.storageLabel = null;
     }
diff --git a/server/src/validation.ts b/server/src/validation.ts
index 26367aeff5..2d160f43ce 100644
--- a/server/src/validation.ts
+++ b/server/src/validation.ts
@@ -18,6 +18,7 @@ import {
   IsOptional,
   IsString,
   IsUUID,
+  Matches,
   Validate,
   ValidateBy,
   ValidateIf,
@@ -70,6 +71,22 @@ export class UUIDParamDto {
   id!: string;
 }
 
+type PinCodeOptions = { optional?: boolean } & OptionalOptions;
+export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => {
+  const decorators = [
+    IsString(),
+    IsNotEmpty(),
+    Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
+    ApiProperty({ example: '123456' }),
+  ];
+
+  if (optional) {
+    decorators.push(Optional(options));
+  }
+
+  return applyDecorators(...decorators);
+};
+
 export interface OptionalOptions extends ValidationOptions {
   nullable?: boolean;
   /** convert empty strings to null */
diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte
new file mode 100644
index 0000000000..e149f26851
--- /dev/null
+++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte
@@ -0,0 +1,114 @@
+<script lang="ts">
+  interface Props {
+    label: string;
+    value?: string;
+    pinLength?: number;
+    tabindexStart?: number;
+  }
+
+  let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
+
+  let pinValues = $state(Array.from({ length: pinLength }).fill(''));
+  let pinCodeInputElements: HTMLInputElement[] = $state([]);
+
+  $effect(() => {
+    if (value === '') {
+      pinValues = Array.from({ length: pinLength }).fill('');
+    }
+  });
+
+  const focusNext = (index: number) => {
+    pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
+  };
+
+  const focusPrev = (index: number) => {
+    if (index > 0) {
+      pinCodeInputElements[index - 1]?.focus();
+    }
+  };
+
+  const handleInput = (event: Event, index: number) => {
+    const target = event.target as HTMLInputElement;
+    let currentPinValue = target.value;
+
+    if (target.value.length > 1) {
+      currentPinValue = value.slice(0, 1);
+    }
+
+    if (Number.isNaN(Number(value))) {
+      pinValues[index] = '';
+      target.value = '';
+      return;
+    }
+
+    pinValues[index] = currentPinValue;
+
+    value = pinValues.join('').trim();
+
+    if (value && index < pinLength - 1) {
+      focusNext(index);
+    }
+  };
+
+  function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
+    const target = event.currentTarget as HTMLInputElement;
+    const index = pinCodeInputElements.indexOf(target);
+
+    switch (event.key) {
+      case 'Tab': {
+        return;
+      }
+      case 'Backspace': {
+        if (target.value === '' && index > 0) {
+          focusPrev(index);
+          pinValues[index - 1] = '';
+        } else if (target.value !== '') {
+          pinValues[index] = '';
+        }
+        return;
+      }
+      case 'ArrowLeft': {
+        if (index > 0) {
+          focusPrev(index);
+        }
+        return;
+      }
+      case 'ArrowRight': {
+        if (index < pinLength - 1) {
+          focusNext(index);
+        }
+        return;
+      }
+      default: {
+        if (Number.isNaN(Number(event.key))) {
+          event.preventDefault();
+        }
+        break;
+      }
+    }
+  }
+</script>
+
+<div class="flex flex-col gap-1">
+  {#if label}
+    <label class="text-xs text-dark" for={pinCodeInputElements[0]?.id}>{label.toUpperCase()}</label>
+  {/if}
+  <div class="flex gap-2">
+    {#each { length: pinLength } as _, index (index)}
+      <input
+        tabindex={tabindexStart + index}
+        type="text"
+        inputmode="numeric"
+        pattern="[0-9]*"
+        maxlength="1"
+        bind:this={pinCodeInputElements[index]}
+        id="pin-code-{index}"
+        class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
+        bind:value={pinValues[index]}
+        onkeydown={handleKeydown}
+        oninput={(event) => handleInput(event, index)}
+        aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
+      />
+    {/each}
+  </div>
+</div>
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte
new file mode 100644
index 0000000000..ef122b14e7
--- /dev/null
+++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
+  import { Button } from '@immich/ui';
+  import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
+  import { fade } from 'svelte/transition';
+
+  let hasPinCode = $state(false);
+  let currentPinCode = $state('');
+  let newPinCode = $state('');
+  let confirmPinCode = $state('');
+  let isLoading = $state(false);
+  let canSubmit = $derived(
+    (hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
+  );
+
+  onMount(async () => {
+    const authStatus = await getAuthStatus();
+    hasPinCode = authStatus.pinCode;
+  });
+
+  const handleSubmit = async (event: Event) => {
+    event.preventDefault();
+    await (hasPinCode ? handleChange() : handleSetup());
+  };
+
+  const handleSetup = async () => {
+    isLoading = true;
+    try {
+      await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
+
+      resetForm();
+
+      notificationController.show({
+        message: $t('pin_code_setup_successfully'),
+        type: NotificationType.Info,
+      });
+    } catch (error) {
+      handleError(error, $t('unable_to_setup_pin_code'));
+    } finally {
+      isLoading = false;
+      hasPinCode = true;
+    }
+  };
+
+  const handleChange = async () => {
+    isLoading = true;
+    try {
+      await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
+
+      resetForm();
+
+      notificationController.show({
+        message: $t('pin_code_changed_successfully'),
+        type: NotificationType.Info,
+      });
+    } catch (error) {
+      handleError(error, $t('unable_to_change_pin_code'));
+    } finally {
+      isLoading = false;
+    }
+  };
+
+  const resetForm = () => {
+    currentPinCode = '';
+    newPinCode = '';
+    confirmPinCode = '';
+  };
+</script>
+
+<section class="my-4">
+  <div in:fade={{ duration: 200 }}>
+    <form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
+      <div class="flex flex-col gap-6 place-items-center place-content-center">
+        {#if hasPinCode}
+          <p class="text-dark">Change PIN code</p>
+          <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
+
+          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
+
+          <PinCodeInput
+            label={$t('confirm_new_pin_code')}
+            bind:value={confirmPinCode}
+            tabindexStart={13}
+            pinLength={6}
+          />
+        {:else}
+          <p class="text-dark">{$t('setup_pin_code')}</p>
+          <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
+
+          <PinCodeInput
+            label={$t('confirm_new_pin_code')}
+            bind:value={confirmPinCode}
+            tabindexStart={7}
+            pinLength={6}
+          />
+        {/if}
+      </div>
+
+      <div class="flex justify-end gap-2 mt-4">
+        <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
+          {$t('clear')}
+        </Button>
+        <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
+          {hasPinCode ? $t('save') : $t('create')}
+        </Button>
+      </div>
+    </form>
+  </div>
+</section>
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte
index 934fa5708f..32747f5ba6 100644
--- a/web/src/lib/components/user-settings-page/user-settings-list.svelte
+++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte
@@ -1,24 +1,16 @@
 <script lang="ts">
   import { page } from '$app/stores';
+  import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte';
+  import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
+  import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
+  import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
+  import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
+  import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
   import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { user } from '$lib/stores/user.store';
   import { oauth } from '$lib/utils';
   import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
-  import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
-  import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
-  import AppSettings from './app-settings.svelte';
-  import ChangePasswordSettings from './change-password-settings.svelte';
-  import DeviceList from './device-list.svelte';
-  import OAuthSettings from './oauth-settings.svelte';
-  import PartnerSettings from './partner-settings.svelte';
-  import UserAPIKeyList from './user-api-key-list.svelte';
-  import UserProfileSettings from './user-profile-settings.svelte';
-  import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
-  import { t } from 'svelte-i18n';
-  import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
-  import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
-  import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
   import {
     mdiAccountGroupOutline,
     mdiAccountOutline,
@@ -29,11 +21,21 @@
     mdiDownload,
     mdiFeatureSearchOutline,
     mdiKeyOutline,
+    mdiLockSmart,
     mdiOnepassword,
     mdiServerOutline,
     mdiTwoFactorAuthentication,
   } from '@mdi/js';
-  import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
+  import { t } from 'svelte-i18n';
+  import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
+  import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
+  import AppSettings from './app-settings.svelte';
+  import ChangePasswordSettings from './change-password-settings.svelte';
+  import DeviceList from './device-list.svelte';
+  import OAuthSettings from './oauth-settings.svelte';
+  import PartnerSettings from './partner-settings.svelte';
+  import UserAPIKeyList from './user-api-key-list.svelte';
+  import UserProfileSettings from './user-profile-settings.svelte';
 
   interface Props {
     keys?: ApiKeyResponseDto[];
@@ -135,6 +137,16 @@
     <PartnerSettings user={$user} />
   </SettingAccordion>
 
+  <SettingAccordion
+    icon={mdiLockSmart}
+    key="user-pin-code-settings"
+    title={$t('user_pin_code_settings')}
+    subtitle={$t('user_pin_code_settings_description')}
+    autoScrollTo={true}
+  >
+    <ChangePinCodeSettings />
+  </SettingAccordion>
+
   <SettingAccordion
     icon={mdiKeyOutline}
     key="user-purchase-settings"
diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte
index 9981aed3ad..5ad0a055c4 100644
--- a/web/src/lib/modals/UserEditModal.svelte
+++ b/web/src/lib/modals/UserEditModal.svelte
@@ -6,14 +6,17 @@
   import { handleError } from '$lib/utils/handle-error';
   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
   import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
-  import { mdiAccountEditOutline } from '@mdi/js';
+  import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
   interface Props {
     user: UserAdminResponseDto;
     canResetPassword?: boolean;
     onClose: (
-      data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string },
+      data?:
+        | { action: 'update'; data: UserAdminResponseDto }
+        | { action: 'resetPassword'; data: string }
+        | { action: 'resetPinCode' },
     ) => void;
   }
 
@@ -76,6 +79,24 @@
     }
   };
 
+  const resetUserPincode = async () => {
+    const isConfirmed = await modalManager.openDialog({
+      prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
+    });
+
+    if (!isConfirmed) {
+      return;
+    }
+
+    try {
+      await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
+
+      onClose({ action: 'resetPinCode' });
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_reset_pin_code'));
+    }
+  };
+
   // TODO move password reset server-side
   function generatePassword(length: number = 16) {
     let generatedPassword = '';
@@ -151,13 +172,34 @@
   </ModalBody>
 
   <ModalFooter>
-    <div class="flex gap-3 w-full">
-      {#if canResetPassword}
-        <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
-          >{$t('reset_password')}</Button
+    <div class="w-full">
+      <div class="flex gap-3 w-full">
+        {#if canResetPassword}
+          <Button
+            shape="round"
+            color="warning"
+            variant="filled"
+            fullWidth
+            onclick={resetPassword}
+            leadingIcon={mdiOnepassword}
+          >
+            {$t('reset_password')}</Button
+          >
+        {/if}
+
+        <Button
+          shape="round"
+          color="warning"
+          variant="filled"
+          fullWidth
+          onclick={resetUserPincode}
+          leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button
         >
-      {/if}
-      <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
+      </div>
+
+      <div class="w-full mt-4">
+        <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
+      </div>
     </div>
   </ModalFooter>
 </Modal>
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index dcf82bd10d..a3887ec454 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -74,6 +74,10 @@
         await refresh();
         break;
       }
+      case 'resetPinCode': {
+        notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') });
+        break;
+      }
     }
   };
 
@@ -137,7 +141,7 @@
                   {#if !immichUser.deletedAt}
                     <IconButton
                       shape="round"
-                      size="small"
+                      size="medium"
                       icon={mdiPencilOutline}
                       title={$t('edit_user')}
                       onclick={() => handleEdit(immichUser)}
@@ -146,7 +150,7 @@
                     {#if immichUser.id !== $user.id}
                       <IconButton
                         shape="round"
-                        size="small"
+                        size="medium"
                         icon={mdiTrashCanOutline}
                         title={$t('delete_user')}
                         onclick={() => handleDelete(immichUser)}