diff --git a/controllers/step_status_reconciler.go b/controllers/step_status_reconciler.go
new file mode 100644
index 0000000000000000000000000000000000000000..df2b8970aaf7b59f8b2873bbd3a7de94ecbbcb38
--- /dev/null
+++ b/controllers/step_status_reconciler.go
@@ -0,0 +1,84 @@
+package controllers
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/go-logr/logr"
+	api "github.com/smallstep/step-issuer/api/v1beta1"
+	core "k8s.io/api/core/v1"
+	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type stepStatusReconciler struct {
+	*StepIssuerReconciler
+	issuer *api.StepIssuer
+	logger logr.Logger
+}
+
+func newStepStatusReconciler(r *StepIssuerReconciler, iss *api.StepIssuer, log logr.Logger) *stepStatusReconciler {
+	return &stepStatusReconciler{
+		StepIssuerReconciler: r,
+		issuer:               iss,
+		logger:               log,
+	}
+}
+
+func (r *stepStatusReconciler) Update(ctx context.Context, status api.ConditionStatus, reason, message string, args ...interface{}) error {
+	completeMessage := fmt.Sprintf(message, args...)
+	r.setCondition(status, reason, completeMessage)
+
+	// Fire an Event to additionally inform users of the change
+	eventType := core.EventTypeNormal
+	if status == api.ConditionFalse {
+		eventType = core.EventTypeWarning
+	}
+	r.Recorder.Event(r.issuer, eventType, reason, completeMessage)
+
+	return r.Client.Update(ctx, r.issuer)
+}
+
+// setCondition will set a 'condition' on the given api.StepIssuer resource.
+//
+// - If no condition of the same type already exists, the condition will be
+//   inserted with the LastTransitionTime set to the current time.
+// - If a condition of the same type and state already exists, the condition
+//   will be updated but the LastTransitionTime will not be modified.
+// - If a condition of the same type and different state already exists, the
+//   condition will be updated and the LastTransitionTime set to the current
+//   time.
+func (r *stepStatusReconciler) setCondition(status api.ConditionStatus, reason, message string) {
+	now := meta.NewTime(r.Clock.Now())
+	c := api.StepIssuerCondition{
+		Type:               api.ConditionReady,
+		Status:             status,
+		Reason:             reason,
+		Message:            message,
+		LastTransitionTime: &now,
+	}
+
+	// Search through existing conditions
+	for idx, cond := range r.issuer.Status.Conditions {
+		// Skip unrelated conditions
+		if cond.Type != api.ConditionReady {
+			continue
+		}
+
+		// If this update doesn't contain a state transition, we don't update
+		// the conditions LastTransitionTime to Now()
+		if cond.Status == status {
+			c.LastTransitionTime = cond.LastTransitionTime
+		} else {
+			r.logger.Info("found status change for StepIssuer condition; setting lastTransitionTime", "condition", cond.Type, "old_status", cond.Status, "new_status", status, "time", now.Time)
+		}
+
+		// Overwrite the existing condition
+		r.issuer.Status.Conditions[idx] = c
+		return
+	}
+
+	// If we've not found an existing condition of this type, we simply insert
+	// the new condition into the slice.
+	r.issuer.Status.Conditions = append(r.issuer.Status.Conditions, c)
+	r.logger.Info("setting lastTransitionTime for StepIssuer condition", "condition", api.ConditionReady, "time", now.Time)
+}
diff --git a/controllers/stepissuer_controller.go b/controllers/stepissuer_controller.go
new file mode 100644
index 0000000000000000000000000000000000000000..04abdd8b9da049a11838a4bcc1d06c728aac19da
--- /dev/null
+++ b/controllers/stepissuer_controller.go
@@ -0,0 +1,122 @@
+/*
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controllers
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/go-logr/logr"
+	api "github.com/smallstep/step-issuer/api/v1beta1"
+	"github.com/smallstep/step-issuer/provisioners"
+	core "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"k8s.io/utils/clock"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// StepIssuerReconciler reconciles a StepIssuer object
+type StepIssuerReconciler struct {
+	client.Client
+	Log      logr.Logger
+	Clock    clock.Clock
+	Recorder record.EventRecorder
+}
+
+// +kubebuilder:rbac:groups=certmanager.step.sm,resources=stepissuers,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=certmanager.step.sm,resources=stepissuers/status,verbs=get;update;patch
+
+// Reconcile will read and validate the StepIssuer resources, it will set the
+// status condition ready to true if everything is right.
+func (r *StepIssuerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
+	ctx := context.Background()
+	log := r.Log.WithValues("stepissuer", req.NamespacedName)
+
+	iss := new(api.StepIssuer)
+	if err := r.Client.Get(ctx, req.NamespacedName, iss); err != nil {
+		log.Error(err, "failed to retrieve StepIssuer resource")
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	statusReconciler := newStepStatusReconciler(r, iss, log)
+	if err := validateStepIssuerSpec(iss.Spec); err != nil {
+		log.Error(err, "failed to validate StepIssuer resource")
+		statusReconciler.Update(ctx, api.ConditionFalse, "Validation", "Failed to validate resource: %v", err)
+		return ctrl.Result{}, err
+	}
+
+	// Fetch the provisioner password
+	var secret core.Secret
+	secretNamespaceName := types.NamespacedName{
+		Namespace: req.Namespace,
+		Name:      iss.Spec.Provisioner.PasswordRef.Name,
+	}
+	if err := r.Client.Get(ctx, secretNamespaceName, &secret); err != nil {
+		log.Error(err, "failed to retrieve StepIssuer provisioner secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name)
+		if apierrors.IsNotFound(err) {
+			statusReconciler.Update(ctx, api.ConditionFalse, "NotFound", "Failed to retrieve provisioner secret: %v", err)
+		} else {
+			statusReconciler.Update(ctx, api.ConditionFalse, "Error", "Failed to retrieve provisioner secret: %v", err)
+		}
+		return ctrl.Result{}, err
+	}
+	password, ok := secret.Data[iss.Spec.Provisioner.PasswordRef.Key]
+	if !ok {
+		err := fmt.Errorf("secret %s does not contain key %s", secret.Name, iss.Spec.Provisioner.PasswordRef.Key)
+		log.Error(err, "failed to retrieve StepIssuer provisioner secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name)
+		statusReconciler.Update(ctx, api.ConditionFalse, "NotFound", "Failed to retrieve provisioner secret: %v", err)
+		return ctrl.Result{}, err
+	}
+
+	// Initialize and store the provisioner
+	p, err := provisioners.New(iss, password)
+	if err != nil {
+		log.Error(err, "failed to initialize provisioner")
+		statusReconciler.Update(ctx, api.ConditionFalse, "Error", "failed initialize provisioner")
+		return ctrl.Result{}, err
+	}
+	provisioners.Store(req.NamespacedName, p)
+
+	return ctrl.Result{}, statusReconciler.Update(ctx, api.ConditionTrue, "Verified", "StepIssuer verified and ready to sign certificates")
+}
+
+// SetupWithManager initializes the StepIssuer controller into the controller
+// runtime.
+func (r *StepIssuerReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&api.StepIssuer{}).
+		Complete(r)
+}
+
+func validateStepIssuerSpec(s api.StepIssuerSpec) error {
+	switch {
+	case s.URL == "":
+		return fmt.Errorf("spec.url cannot be empty")
+	case s.Provisioner.Name == "":
+		return fmt.Errorf("spec.provisioner.name cannot be empty")
+	case s.Provisioner.KeyID == "":
+		return fmt.Errorf("spec.provisioner.kid cannot be empty")
+	case s.Provisioner.PasswordRef.Name == "":
+		return fmt.Errorf("spec.provisioner.passwordRef.name cannot be empty")
+	case s.Provisioner.PasswordRef.Key == "":
+		return fmt.Errorf("spec.provisioner.passwordRef.key cannot be empty")
+	default:
+		return nil
+	}
+}