diff --git a/config/crds/v1/crds.kubeflare.io_accessapplications.yaml b/config/crds/v1/crds.kubeflare.io_accessapplications.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..6abee7f3093876382715fccf1dcb4a0751371515
--- /dev/null
+++ b/config/crds/v1/crds.kubeflare.io_accessapplications.yaml
@@ -0,0 +1,135 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.2.8
+  creationTimestamp: null
+  name: accessapplications.crds.kubeflare.io
+spec:
+  group: crds.kubeflare.io
+  names:
+    kind: AccessApplication
+    listKind: AccessApplicationList
+    plural: accessapplications
+    singular: accessapplication
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: DNSRecord is the Schema for the accessapplication API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AccessApplicationSpec defines the desired state of AccessApplication
+            properties:
+              accessPolicies:
+                items:
+                  properties:
+                    descision:
+                      type: string
+                    exclude:
+                      items:
+                        type: string
+                      type: array
+                    include:
+                      items:
+                        type: string
+                      type: array
+                    name:
+                      type: string
+                    precendence:
+                      type: integer
+                    require:
+                      items:
+                        type: string
+                      type: array
+                  required:
+                  - descision
+                  - include
+                  - name
+                  type: object
+                type: array
+              allowedIdPs:
+                items:
+                  type: string
+                type: array
+              autoRedirectToIndentiy:
+                type: boolean
+              corsHeaders:
+                properties:
+                  allowAllHeaders:
+                    type: boolean
+                  allowAllMethods:
+                    type: boolean
+                  allowAllOrigins:
+                    type: boolean
+                  allowCredentials:
+                    type: boolean
+                  allowedHeader:
+                    items:
+                      type: string
+                    type: array
+                  allowedMethods:
+                    items:
+                      type: string
+                    type: array
+                  allowedOrigins:
+                    items:
+                      type: string
+                    type: array
+                  maxAge:
+                    type: integer
+                required:
+                - allowAllHeaders
+                - allowAllMethods
+                - allowAllOrigins
+                - allowCredentials
+                - allowedHeader
+                - allowedMethods
+                - allowedOrigins
+                - maxAge
+                type: object
+              domain:
+                type: string
+              name:
+                type: string
+              sessionDuration:
+                type: string
+              zone:
+                type: string
+            required:
+            - domain
+            - name
+            - zone
+            type: object
+          status:
+            description: AccessApplicationStatus defines the observed state of AccessApplicationS
+            properties:
+              applicationID:
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/crds/v1beta1/crds.kubeflare.io_accessapplications.yaml b/config/crds/v1beta1/crds.kubeflare.io_accessapplications.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..babcadbed38f5295a1de29739de53e8a23d455ca
--- /dev/null
+++ b/config/crds/v1beta1/crds.kubeflare.io_accessapplications.yaml
@@ -0,0 +1,136 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.2.8
+  creationTimestamp: null
+  name: accessapplications.crds.kubeflare.io
+spec:
+  group: crds.kubeflare.io
+  names:
+    kind: AccessApplication
+    listKind: AccessApplicationList
+    plural: accessapplications
+    singular: accessapplication
+  scope: Namespaced
+  subresources:
+    status: {}
+  validation:
+    openAPIV3Schema:
+      description: DNSRecord is the Schema for the accessapplication API
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          description: AccessApplicationSpec defines the desired state of AccessApplication
+          properties:
+            accessPolicies:
+              items:
+                properties:
+                  descision:
+                    type: string
+                  exclude:
+                    items:
+                      type: string
+                    type: array
+                  include:
+                    items:
+                      type: string
+                    type: array
+                  name:
+                    type: string
+                  precendence:
+                    type: integer
+                  require:
+                    items:
+                      type: string
+                    type: array
+                required:
+                - descision
+                - include
+                - name
+                type: object
+              type: array
+            allowedIdPs:
+              items:
+                type: string
+              type: array
+            autoRedirectToIndentiy:
+              type: boolean
+            corsHeaders:
+              properties:
+                allowAllHeaders:
+                  type: boolean
+                allowAllMethods:
+                  type: boolean
+                allowAllOrigins:
+                  type: boolean
+                allowCredentials:
+                  type: boolean
+                allowedHeader:
+                  items:
+                    type: string
+                  type: array
+                allowedMethods:
+                  items:
+                    type: string
+                  type: array
+                allowedOrigins:
+                  items:
+                    type: string
+                  type: array
+                maxAge:
+                  type: integer
+              required:
+              - allowAllHeaders
+              - allowAllMethods
+              - allowAllOrigins
+              - allowCredentials
+              - allowedHeader
+              - allowedMethods
+              - allowedOrigins
+              - maxAge
+              type: object
+            domain:
+              type: string
+            name:
+              type: string
+            sessionDuration:
+              type: string
+            zone:
+              type: string
+          required:
+          - domain
+          - name
+          - zone
+          type: object
+        status:
+          description: AccessApplicationStatus defines the observed state of AccessApplicationS
+          properties:
+            applicationID:
+              type: string
+          type: object
+      type: object
+  version: v1alpha1
+  versions:
+  - name: v1alpha1
+    served: true
+    storage: true
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/go.mod b/go.mod
index 6d61f66357df94e1b9b3a52f13877285de905923..25ebb49635f9341e3fbbc6c49c309b3aa4e95887 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	github.com/xo/dburl v0.0.0-20200124232849-e9ec94f52bc3
 	go.uber.org/zap v1.10.0
 	gopkg.in/yaml.v2 v2.2.8
+	gotest.tools v2.2.0+incompatible
 	k8s.io/api v0.18.0
 	k8s.io/apiextensions-apiserver v0.18.0
 	k8s.io/apimachinery v0.18.0
diff --git a/go.sum b/go.sum
index ad845d8314c3daf9e17826093c94ebfa811dfeef..46837d607b56bdccc645b544e1c4aa9900cbe959 100644
--- a/go.sum
+++ b/go.sum
@@ -596,6 +596,7 @@ gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966 h1:B0J02caTR6tpSJozBJyiAzT6C
 gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/pkg/apis/crds/v1alpha1/accessapplication_types.go b/pkg/apis/crds/v1alpha1/accessapplication_types.go
new file mode 100644
index 0000000000000000000000000000000000000000..cbfacd9399450acc00535239bd66caa175266944
--- /dev/null
+++ b/pkg/apis/crds/v1alpha1/accessapplication_types.go
@@ -0,0 +1,85 @@
+/*
+Copyright 2019 Replicated, Inc.
+
+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 v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type AccessPolicy struct {
+	Decision   string   `json:"descision"`
+	Name       string   `json:"name"`
+	Include    []string `json:"include"` // TODO
+	Precedence *int     `json:"precendence,omitempty"`
+	Exclude    []string `json:"exclude,omitempty"` // TODO
+	Require    []string `json:"require,omitempty"` // TODO
+}
+
+type CORSHeader struct {
+	AllowedMethods   []string `json:"allowedMethods"`
+	AllowedOrigins   []string `json:"allowedOrigins"`
+	AllowedHeaders   []string `json:"allowedHeader"`
+	AllowAllMethods  bool     `json:"allowAllMethods"`
+	AllowAllOrigins  bool     `json:"allowAllOrigins"`
+	AllowAllHeaders  bool     `json:"allowAllHeaders"`
+	AllowCredentials bool     `json:"allowCredentials"`
+	MaxAge           int      `json:"maxAge"`
+}
+
+// AccessApplicationSpec defines the desired state of AccessApplication
+type AccessApplicationSpec struct {
+	Zone                   string         `json:"zone"`
+	Name                   string         `json:"name"`
+	Domain                 string         `json:"domain"`
+	SessionDuration        string         `json:"sessionDuration,omitempty"`
+	AllowedIdPs            []string       `json:"allowedIdPs,omitempty"`
+	AutoRedirectToIdentity *bool          `json:"autoRedirectToIndentiy,omitempty"`
+	CORSHeaders            *CORSHeader    `json:"corsHeaders,omitempty"`
+	AccessPolicies         []AccessPolicy `json:"accessPolicies,omitempty"`
+}
+
+// AccessApplicationStatus defines the observed state of AccessApplicationS
+type AccessApplicationStatus struct {
+	ApplicationID string `json:"applicationID,omitempty"`
+}
+
+// +genclient
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// DNSRecord is the Schema for the accessapplication API
+// +k8s:openapi-gen=true
+// +kubebuilder:subresource:status
+type AccessApplication struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   AccessApplicationSpec   `json:"spec,omitempty"`
+	Status AccessApplicationStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// AccessApplicationList contains a list of DNSRecord
+type AccessApplicationList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []DNSRecord `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&AccessApplication{}, &AccessApplicationList{})
+}
diff --git a/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go
index a76f9e20d912fcbcdcc414df82150fd7caaea076..303b6b16441343898355b6dc501fab194139c286 100644
--- a/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go
@@ -119,6 +119,182 @@ func (in *APITokenStatus) DeepCopy() *APITokenStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessApplication) DeepCopyInto(out *AccessApplication) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessApplication.
+func (in *AccessApplication) DeepCopy() *AccessApplication {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessApplication)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AccessApplication) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessApplicationList) DeepCopyInto(out *AccessApplicationList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]DNSRecord, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessApplicationList.
+func (in *AccessApplicationList) DeepCopy() *AccessApplicationList {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessApplicationList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AccessApplicationList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessApplicationSpec) DeepCopyInto(out *AccessApplicationSpec) {
+	*out = *in
+	if in.AllowedIdPs != nil {
+		in, out := &in.AllowedIdPs, &out.AllowedIdPs
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AutoRedirectToIdentity != nil {
+		in, out := &in.AutoRedirectToIdentity, &out.AutoRedirectToIdentity
+		*out = new(bool)
+		**out = **in
+	}
+	if in.CORSHeaders != nil {
+		in, out := &in.CORSHeaders, &out.CORSHeaders
+		*out = new(CORSHeader)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.AccessPolicies != nil {
+		in, out := &in.AccessPolicies, &out.AccessPolicies
+		*out = make([]AccessPolicy, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessApplicationSpec.
+func (in *AccessApplicationSpec) DeepCopy() *AccessApplicationSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessApplicationSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessApplicationStatus) DeepCopyInto(out *AccessApplicationStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessApplicationStatus.
+func (in *AccessApplicationStatus) DeepCopy() *AccessApplicationStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessApplicationStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AccessPolicy) DeepCopyInto(out *AccessPolicy) {
+	*out = *in
+	if in.Include != nil {
+		in, out := &in.Include, &out.Include
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Precedence != nil {
+		in, out := &in.Precedence, &out.Precedence
+		*out = new(int)
+		**out = **in
+	}
+	if in.Exclude != nil {
+		in, out := &in.Exclude, &out.Exclude
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Require != nil {
+		in, out := &in.Require, &out.Require
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessPolicy.
+func (in *AccessPolicy) DeepCopy() *AccessPolicy {
+	if in == nil {
+		return nil
+	}
+	out := new(AccessPolicy)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CORSHeader) DeepCopyInto(out *CORSHeader) {
+	*out = *in
+	if in.AllowedMethods != nil {
+		in, out := &in.AllowedMethods, &out.AllowedMethods
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AllowedOrigins != nil {
+		in, out := &in.AllowedOrigins, &out.AllowedOrigins
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AllowedHeaders != nil {
+		in, out := &in.AllowedHeaders, &out.AllowedHeaders
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CORSHeader.
+func (in *CORSHeader) DeepCopy() *CORSHeader {
+	if in == nil {
+		return nil
+	}
+	out := new(CORSHeader)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *DNSRecord) DeepCopyInto(out *DNSRecord) {
 	*out = *in
diff --git a/pkg/cli/managercli/run.go b/pkg/cli/managercli/run.go
index 2df12a220ac313ec451d8bf5b5daff0ab67e4e4a..7ac5a49a1ea0d4855292195e4507a1771eab2a77 100644
--- a/pkg/cli/managercli/run.go
+++ b/pkg/cli/managercli/run.go
@@ -4,6 +4,8 @@ import (
 	"os"
 
 	"github.com/replicatedhq/kubeflare/pkg/apis"
+	accessapplicationcontroller "github.com/replicatedhq/kubeflare/pkg/controller/accessapplication"
+	dnsrecordcontroller "github.com/replicatedhq/kubeflare/pkg/controller/dnsrecord"
 	zonecontroller "github.com/replicatedhq/kubeflare/pkg/controller/zone"
 	"github.com/replicatedhq/kubeflare/pkg/logger"
 	"github.com/replicatedhq/kubeflare/pkg/version"
@@ -65,6 +67,16 @@ func RunCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
+			if err := dnsrecordcontroller.Add(mgr); err != nil {
+				logger.Error(err)
+				os.Exit(1)
+			}
+
+			if err := accessapplicationcontroller.Add(mgr); err != nil {
+				logger.Error(err)
+				os.Exit(1)
+			}
+
 			if err := webhook.AddToManager(mgr); err != nil {
 				logger.Error(err)
 				os.Exit(1)
diff --git a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/accessapplication.go b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/accessapplication.go
new file mode 100644
index 0000000000000000000000000000000000000000..7a87642515e3a32651f93e7ccc943d1635cdea22
--- /dev/null
+++ b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/accessapplication.go
@@ -0,0 +1,195 @@
+/*
+Copyright 2020 Replicated, Inc.
+
+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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"context"
+	"time"
+
+	v1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	scheme "github.com/replicatedhq/kubeflare/pkg/client/kubeflareclientset/scheme"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	rest "k8s.io/client-go/rest"
+)
+
+// AccessApplicationsGetter has a method to return a AccessApplicationInterface.
+// A group's client should implement this interface.
+type AccessApplicationsGetter interface {
+	AccessApplications(namespace string) AccessApplicationInterface
+}
+
+// AccessApplicationInterface has methods to work with AccessApplication resources.
+type AccessApplicationInterface interface {
+	Create(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.CreateOptions) (*v1alpha1.AccessApplication, error)
+	Update(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (*v1alpha1.AccessApplication, error)
+	UpdateStatus(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (*v1alpha1.AccessApplication, error)
+	Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
+	DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
+	Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.AccessApplication, error)
+	List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.AccessApplicationList, error)
+	Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
+	Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.AccessApplication, err error)
+	AccessApplicationExpansion
+}
+
+// accessApplications implements AccessApplicationInterface
+type accessApplications struct {
+	client rest.Interface
+	ns     string
+}
+
+// newAccessApplications returns a AccessApplications
+func newAccessApplications(c *CrdsV1alpha1Client, namespace string) *accessApplications {
+	return &accessApplications{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the accessApplication, and returns the corresponding accessApplication object, and an error if there is any.
+func (c *accessApplications) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.AccessApplication, err error) {
+	result = &v1alpha1.AccessApplication{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of AccessApplications that match those selectors.
+func (c *accessApplications) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.AccessApplicationList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1alpha1.AccessApplicationList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested accessApplications.
+func (c *accessApplications) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	opts.Watch = true
+	return c.client.Get().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch(ctx)
+}
+
+// Create takes the representation of a accessApplication and creates it.  Returns the server's representation of the accessApplication, and an error, if there is any.
+func (c *accessApplications) Create(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.CreateOptions) (result *v1alpha1.AccessApplication, err error) {
+	result = &v1alpha1.AccessApplication{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(accessApplication).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Update takes the representation of a accessApplication and updates it. Returns the server's representation of the accessApplication, and an error, if there is any.
+func (c *accessApplications) Update(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (result *v1alpha1.AccessApplication, err error) {
+	result = &v1alpha1.AccessApplication{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		Name(accessApplication.Name).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(accessApplication).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// UpdateStatus was generated because the type contains a Status member.
+// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
+func (c *accessApplications) UpdateStatus(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (result *v1alpha1.AccessApplication, err error) {
+	result = &v1alpha1.AccessApplication{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		Name(accessApplication.Name).
+		SubResource("status").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(accessApplication).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Delete takes name of the accessApplication and deletes it. Returns an error if one occurs.
+func (c *accessApplications) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		Name(name).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *accessApplications) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	var timeout time.Duration
+	if listOpts.TimeoutSeconds != nil {
+		timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second
+	}
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("accessapplications").
+		VersionedParams(&listOpts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// Patch applies the patch and returns the patched accessApplication.
+func (c *accessApplications) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.AccessApplication, err error) {
+	result = &v1alpha1.AccessApplication{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("accessapplications").
+		Name(name).
+		SubResource(subresources...).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(data).
+		Do(ctx).
+		Into(result)
+	return
+}
diff --git a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/crds_client.go b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/crds_client.go
index ddacc9020fb61252d5030b31413136a841c95346..ae15e2e0d26d250c0892bea6fba5b7e00ccc3f89 100644
--- a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/crds_client.go
+++ b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/crds_client.go
@@ -27,6 +27,7 @@ import (
 type CrdsV1alpha1Interface interface {
 	RESTClient() rest.Interface
 	APITokensGetter
+	AccessApplicationsGetter
 	DNSRecordsGetter
 	ZonesGetter
 }
@@ -40,6 +41,10 @@ func (c *CrdsV1alpha1Client) APITokens(namespace string) APITokenInterface {
 	return newAPITokens(c, namespace)
 }
 
+func (c *CrdsV1alpha1Client) AccessApplications(namespace string) AccessApplicationInterface {
+	return newAccessApplications(c, namespace)
+}
+
 func (c *CrdsV1alpha1Client) DNSRecords(namespace string) DNSRecordInterface {
 	return newDNSRecords(c, namespace)
 }
diff --git a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_accessapplication.go b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_accessapplication.go
new file mode 100644
index 0000000000000000000000000000000000000000..4b7a4c40481c28be571c835a32f07171a00dc29b
--- /dev/null
+++ b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_accessapplication.go
@@ -0,0 +1,142 @@
+/*
+Copyright 2020 Replicated, Inc.
+
+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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	"context"
+
+	v1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	testing "k8s.io/client-go/testing"
+)
+
+// FakeAccessApplications implements AccessApplicationInterface
+type FakeAccessApplications struct {
+	Fake *FakeCrdsV1alpha1
+	ns   string
+}
+
+var accessapplicationsResource = schema.GroupVersionResource{Group: "crds.kubeflare.io", Version: "v1alpha1", Resource: "accessapplications"}
+
+var accessapplicationsKind = schema.GroupVersionKind{Group: "crds.kubeflare.io", Version: "v1alpha1", Kind: "AccessApplication"}
+
+// Get takes name of the accessApplication, and returns the corresponding accessApplication object, and an error if there is any.
+func (c *FakeAccessApplications) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.AccessApplication, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(accessapplicationsResource, c.ns, name), &v1alpha1.AccessApplication{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.AccessApplication), err
+}
+
+// List takes label and field selectors, and returns the list of AccessApplications that match those selectors.
+func (c *FakeAccessApplications) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.AccessApplicationList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(accessapplicationsResource, accessapplicationsKind, c.ns, opts), &v1alpha1.AccessApplicationList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1alpha1.AccessApplicationList{ListMeta: obj.(*v1alpha1.AccessApplicationList).ListMeta}
+	for _, item := range obj.(*v1alpha1.AccessApplicationList).Items {
+		if label.Matches(labels.Set(item.Labels)) {
+			list.Items = append(list.Items, item)
+		}
+	}
+	return list, err
+}
+
+// Watch returns a watch.Interface that watches the requested accessApplications.
+func (c *FakeAccessApplications) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(accessapplicationsResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a accessApplication and creates it.  Returns the server's representation of the accessApplication, and an error, if there is any.
+func (c *FakeAccessApplications) Create(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.CreateOptions) (result *v1alpha1.AccessApplication, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(accessapplicationsResource, c.ns, accessApplication), &v1alpha1.AccessApplication{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.AccessApplication), err
+}
+
+// Update takes the representation of a accessApplication and updates it. Returns the server's representation of the accessApplication, and an error, if there is any.
+func (c *FakeAccessApplications) Update(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (result *v1alpha1.AccessApplication, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(accessapplicationsResource, c.ns, accessApplication), &v1alpha1.AccessApplication{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.AccessApplication), err
+}
+
+// UpdateStatus was generated because the type contains a Status member.
+// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
+func (c *FakeAccessApplications) UpdateStatus(ctx context.Context, accessApplication *v1alpha1.AccessApplication, opts v1.UpdateOptions) (*v1alpha1.AccessApplication, error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateSubresourceAction(accessapplicationsResource, "status", c.ns, accessApplication), &v1alpha1.AccessApplication{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.AccessApplication), err
+}
+
+// Delete takes name of the accessApplication and deletes it. Returns an error if one occurs.
+func (c *FakeAccessApplications) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteAction(accessapplicationsResource, c.ns, name), &v1alpha1.AccessApplication{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakeAccessApplications) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(accessapplicationsResource, c.ns, listOpts)
+
+	_, err := c.Fake.Invokes(action, &v1alpha1.AccessApplicationList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched accessApplication.
+func (c *FakeAccessApplications) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.AccessApplication, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(accessapplicationsResource, c.ns, name, pt, data, subresources...), &v1alpha1.AccessApplication{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.AccessApplication), err
+}
diff --git a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_crds_client.go b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_crds_client.go
index 5360f0839761ad68496867c0b2a3def7d319beae..ea2128c8d5a27f385ee7519be03547763601b3e2 100644
--- a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_crds_client.go
+++ b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/fake/fake_crds_client.go
@@ -32,6 +32,10 @@ func (c *FakeCrdsV1alpha1) APITokens(namespace string) v1alpha1.APITokenInterfac
 	return &FakeAPITokens{c, namespace}
 }
 
+func (c *FakeCrdsV1alpha1) AccessApplications(namespace string) v1alpha1.AccessApplicationInterface {
+	return &FakeAccessApplications{c, namespace}
+}
+
 func (c *FakeCrdsV1alpha1) DNSRecords(namespace string) v1alpha1.DNSRecordInterface {
 	return &FakeDNSRecords{c, namespace}
 }
diff --git a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/generated_expansion.go b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/generated_expansion.go
index 8e703fb86304ac24f845129fbdd4396ec7ad646d..f7ede82b128b75e2d5bc6c9a40cc7ae7eb16565d 100644
--- a/pkg/client/kubeflareclientset/typed/crds/v1alpha1/generated_expansion.go
+++ b/pkg/client/kubeflareclientset/typed/crds/v1alpha1/generated_expansion.go
@@ -20,6 +20,8 @@ package v1alpha1
 
 type APITokenExpansion interface{}
 
+type AccessApplicationExpansion interface{}
+
 type DNSRecordExpansion interface{}
 
 type ZoneExpansion interface{}
diff --git a/pkg/controller/accessapplication/access_applications.go b/pkg/controller/accessapplication/access_applications.go
new file mode 100644
index 0000000000000000000000000000000000000000..f1c7582f5601eca86628a09f64fc1cbed2a76bae
--- /dev/null
+++ b/pkg/controller/accessapplication/access_applications.go
@@ -0,0 +1,216 @@
+package accessapplication
+
+import (
+	"context"
+	"sort"
+
+	"github.com/cloudflare/cloudflare-go"
+	"github.com/pkg/errors"
+	crdsv1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	"github.com/replicatedhq/kubeflare/pkg/logger"
+)
+
+func ReconcileAccessApplicationInstance(ctx context.Context, instance crdsv1alpha1.AccessApplication, zone *crdsv1alpha1.Zone, cf *cloudflare.API) (*cloudflare.AccessApplication, error) {
+	logger.Debug("reconcileAccessApplication for zone")
+
+	if instance.Status.ApplicationID == "" {
+		accessApplication := cloudflare.AccessApplication{
+			Name:   instance.Spec.Name,
+			Domain: instance.Spec.Domain,
+		}
+
+		if instance.Spec.SessionDuration != "" {
+			accessApplication.SessionDuration = instance.Spec.SessionDuration
+		}
+		if instance.Spec.AllowedIdPs != nil && len(instance.Spec.AllowedIdPs) > 0 {
+			accessApplication.AllowedIdps = instance.Spec.AllowedIdPs
+		}
+		if instance.Spec.AutoRedirectToIdentity != nil {
+			accessApplication.AutoRedirectToIdentity = *instance.Spec.AutoRedirectToIdentity
+		}
+		if instance.Spec.CORSHeaders != nil {
+			corsHeaders := cloudflare.AccessApplicationCorsHeaders{
+				AllowedMethods:   instance.Spec.CORSHeaders.AllowedMethods,
+				AllowedOrigins:   instance.Spec.CORSHeaders.AllowedOrigins,
+				AllowedHeaders:   instance.Spec.CORSHeaders.AllowedHeaders,
+				AllowAllMethods:  instance.Spec.CORSHeaders.AllowAllMethods,
+				AllowAllOrigins:  instance.Spec.CORSHeaders.AllowAllOrigins,
+				AllowAllHeaders:  instance.Spec.CORSHeaders.AllowAllHeaders,
+				AllowCredentials: instance.Spec.CORSHeaders.AllowCredentials,
+				MaxAge:           instance.Spec.CORSHeaders.MaxAge,
+			}
+
+			accessApplication.CorsHeaders = &corsHeaders
+		}
+
+		if err := createAccessApplication(cf, &accessApplication); err != nil {
+			return nil, errors.Wrap(err, "failed to create access application")
+		}
+
+		return &accessApplication, nil
+	}
+
+	existingApplication, err := getExistingAccessApplicationFromID(instance, zone, cf)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get existing application")
+	}
+
+	if existingApplication == nil {
+		// this happens when we have a status resource with an id but it was deleted from cloudflare
+		// TODO we need to recreate the app and update the status resource here
+		return nil, errors.New("application not found, maybe it was deleted.  update the status.applicationID to remove it from this resource and reconcile again")
+	}
+
+	hasChanges := false
+
+	toUpdate := cloudflare.AccessApplication{
+		ID:     existingApplication.ID,
+		Name:   instance.Spec.Name,
+		Domain: instance.Spec.Domain,
+	}
+
+	if instance.Spec.Name != existingApplication.Name {
+		hasChanges = true
+	}
+	if instance.Spec.Domain != existingApplication.Domain {
+		hasChanges = true
+	}
+
+	if instance.Spec.SessionDuration != "" {
+		if instance.Spec.SessionDuration != existingApplication.SessionDuration {
+			hasChanges = true
+			toUpdate.SessionDuration = instance.Spec.SessionDuration
+		}
+	}
+	if instance.Spec.AllowedIdPs != nil {
+		sort.Strings(instance.Spec.AllowedIdPs)
+		sort.Strings(existingApplication.AllowedIdps)
+
+		idpsChanged := len(instance.Spec.AllowedIdPs) != len(existingApplication.AllowedIdps)
+
+		if !idpsChanged {
+			for i, v := range instance.Spec.AllowedIdPs {
+				if v != existingApplication.AllowedIdps[i] {
+					idpsChanged = true
+				}
+			}
+		}
+
+		if idpsChanged {
+			hasChanges = true
+			toUpdate.AllowedIdps = instance.Spec.AllowedIdPs
+		}
+	}
+	if instance.Spec.AutoRedirectToIdentity != nil {
+		if existingApplication.AutoRedirectToIdentity != *instance.Spec.AutoRedirectToIdentity {
+			hasChanges = true
+			toUpdate.AutoRedirectToIdentity = *instance.Spec.AutoRedirectToIdentity
+		}
+	}
+	if instance.Spec.CORSHeaders != nil {
+		updatedCORSHeaders, err := diffCORSHeaders(existingApplication.CorsHeaders, instance.Spec.CORSHeaders)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to diff cors headers")
+		}
+		if updatedCORSHeaders != nil {
+			hasChanges = true
+			toUpdate.CorsHeaders = updatedCORSHeaders
+		}
+	}
+
+	if hasChanges {
+		if err := updateAccessApplication(cf, &toUpdate); err != nil {
+			return nil, errors.Wrap(err, "failed to update access application")
+		}
+
+		existingApplication = &toUpdate
+	}
+
+	if len(instance.Spec.AccessPolicies) > 0 {
+		// TODO
+		existingAccessPolicies := []cloudflare.AccessPolicy{} // TODO
+
+		toCreate, toUpdate, toDelete, err := diffAccessPolicies(existingAccessPolicies, instance.Spec.AccessPolicies)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to diff access policies")
+		}
+
+		for _, ap := range toCreate {
+			_, err := cf.CreateAccessPolicy(cf.AccountID, existingApplication.ID, ap)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to create access policy")
+			}
+		}
+		for _, ap := range toUpdate {
+			_, err := cf.UpdateAccessPolicy(cf.AccountID, existingApplication.ID, ap)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to update access policy")
+			}
+		}
+		for _, ap := range toDelete {
+			err := cf.DeleteAccessPolicy(cf.AccountID, existingApplication.ID, ap.ID)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to delete access policy")
+			}
+		}
+	}
+
+	return existingApplication, nil
+}
+
+// getExistingAccessApplicationFromID will look at the status subresource and if there's an ID
+// will get that. if not, it will a
+func getExistingAccessApplicationFromID(instance crdsv1alpha1.AccessApplication, zone *crdsv1alpha1.Zone, cf *cloudflare.API) (*cloudflare.AccessApplication, error) {
+	accessApplication, err := cf.AccessApplication(cf.AccountID, instance.Status.ApplicationID)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get accessapplication from cf")
+	}
+
+	return &accessApplication, nil
+}
+
+func findExistingAccessApplication(instance crdsv1alpha1.AccessApplication, zone *crdsv1alpha1.Zone, cf *cloudflare.API) (*cloudflare.AccessApplication, error) {
+	currentPage := cloudflare.PaginationOptions{
+		PerPage: 20,
+		Page:    0,
+	}
+
+	for {
+		accessApplications, resultInfo, err := cf.AccessApplications(cf.AccountID, currentPage)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to get page of accessapplications")
+		}
+
+		for _, accessApplication := range accessApplications {
+			if accessApplication.Domain == instance.Spec.Domain && accessApplication.Name == instance.Spec.Name {
+				return &accessApplication, nil
+			}
+		}
+
+		currentPage.Page++
+
+		if currentPage.Page >= resultInfo.TotalPages {
+			return nil, nil
+		}
+	}
+}
+
+func createAccessApplication(cf *cloudflare.API, application *cloudflare.AccessApplication) error {
+	createdApplication, err := cf.CreateAccessApplication(cf.AccountID, *application)
+	if err != nil {
+		return errors.Wrap(err, "failed to create access application")
+	}
+
+	application = &createdApplication
+	return nil
+}
+
+func updateAccessApplication(cf *cloudflare.API, application *cloudflare.AccessApplication) error {
+	updatedApplication, err := cf.UpdateAccessApplication(cf.AccountID, *application)
+	if err != nil {
+		return errors.Wrap(err, "failed to update access application")
+	}
+
+	application = &updatedApplication
+	return nil
+}
diff --git a/pkg/controller/accessapplication/access_policies.go b/pkg/controller/accessapplication/access_policies.go
new file mode 100644
index 0000000000000000000000000000000000000000..02883cc8541197cbf40cfd85eb074997e797bd89
--- /dev/null
+++ b/pkg/controller/accessapplication/access_policies.go
@@ -0,0 +1,117 @@
+package accessapplication
+
+import (
+	"github.com/cloudflare/cloudflare-go"
+	"github.com/pkg/errors"
+	crdsv1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	"github.com/replicatedhq/kubeflare/pkg/controller/shared"
+)
+
+func diffAccessPolicies(existings []cloudflare.AccessPolicy, desireds []crdsv1alpha1.AccessPolicy) ([]cloudflare.AccessPolicy, []cloudflare.AccessPolicy, []cloudflare.AccessPolicy, error) {
+	toCreate := []cloudflare.AccessPolicy{}
+	toUpdate := []cloudflare.AccessPolicy{}
+	toDelete := []cloudflare.AccessPolicy{}
+
+	if len(existings) == 0 && len(desireds) == 0 {
+		return toCreate, toUpdate, toDelete, nil
+	}
+
+	for _, existing := range existings {
+		found := false
+		for _, desired := range desireds {
+			// TODO we should store these by ID in the status field
+			// with this implementation, renaming would delete and recreate
+			if desired.Name == existing.Name {
+				found = true
+
+				updated, err := diffAccessPolicy(existing, desired)
+				if err != nil {
+					return nil, nil, nil, errors.Wrap(err, "failed to diff policies")
+				}
+
+				if updated != nil {
+					toUpdate = append(toUpdate, *updated)
+				}
+
+				goto Found
+			}
+		}
+
+	Found:
+		if !found {
+			toDelete = append(toDelete, existing)
+		}
+	}
+
+	for _, desired := range desireds {
+		found := false
+		for _, existing := range existings {
+			if existing.Name == desired.Name {
+				found = true
+				goto Found2
+			}
+		}
+
+	Found2:
+		if !found {
+			create := cloudflare.AccessPolicy{
+				Name:     desired.Name,
+				Decision: desired.Decision,
+				Include:  shared.StringArrayToInterfaceArray(desired.Include),
+				Exclude:  shared.StringArrayToInterfaceArray(desired.Exclude),
+				Require:  shared.StringArrayToInterfaceArray(desired.Require),
+			}
+
+			if desired.Precedence != nil {
+				create.Precedence = *desired.Precedence
+			}
+
+			toCreate = append(toCreate, create)
+		}
+	}
+	return toCreate, toUpdate, toDelete, nil
+}
+
+// diffAccessPolicy will diff existing to desired.
+// if there are diffs, it will return not-nil in the first response param
+func diffAccessPolicy(existing cloudflare.AccessPolicy, desired crdsv1alpha1.AccessPolicy) (*cloudflare.AccessPolicy, error) {
+	hasChanged := false
+
+	if existing.Name != desired.Name {
+		hasChanged = true
+		existing.Name = desired.Name
+	}
+
+	if existing.Decision != desired.Decision {
+		hasChanged = true
+		existing.Decision = desired.Decision
+	}
+
+	if desired.Precedence != nil {
+		if existing.Precedence != *desired.Precedence {
+			hasChanged = true
+			existing.Precedence = *desired.Precedence
+		}
+	}
+
+	if !shared.StringSlicesMatch(shared.InterfaceArrayToStringArray(existing.Include), desired.Include) {
+		hasChanged = true
+		existing.Include = shared.StringArrayToInterfaceArray(desired.Include)
+	}
+
+	if !shared.StringSlicesMatch(shared.InterfaceArrayToStringArray(existing.Exclude), desired.Exclude) {
+		hasChanged = true
+		existing.Exclude = shared.StringArrayToInterfaceArray(desired.Exclude)
+	}
+
+	if !shared.StringSlicesMatch(shared.InterfaceArrayToStringArray(existing.Require), desired.Require) {
+		hasChanged = true
+		existing.Require = shared.StringArrayToInterfaceArray(desired.Require)
+	}
+
+	if !hasChanged {
+		return nil, nil
+	}
+
+	return &existing, nil
+}
diff --git a/pkg/controller/accessapplication/accessapplication_controller.go b/pkg/controller/accessapplication/accessapplication_controller.go
new file mode 100644
index 0000000000000000000000000000000000000000..ac660189d780a427dd3c21a58213d3a56de9c110
--- /dev/null
+++ b/pkg/controller/accessapplication/accessapplication_controller.go
@@ -0,0 +1,142 @@
+/*
+Copyright 2019 Replicated, Inc.
+
+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 accessapplication
+
+import (
+	"context"
+	"time"
+
+	"github.com/pkg/errors"
+	crdsv1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	"github.com/replicatedhq/kubeflare/pkg/controller/shared"
+	"github.com/replicatedhq/kubeflare/pkg/logger"
+	"k8s.io/apimachinery/pkg/runtime"
+	kubeinformers "k8s.io/client-go/informers"
+	"k8s.io/client-go/kubernetes"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+)
+
+// Add creates a new AccessApplication Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller
+// and Start it when the Manager is Started.
+func Add(mgr manager.Manager) error {
+	return add(mgr, newReconciler(mgr))
+}
+
+// newReconciler returns a new reconcile.Reconciler
+func newReconciler(mgr manager.Manager) reconcile.Reconciler {
+	return &ReconcileAccessApplication{
+		Client: mgr.GetClient(),
+		scheme: mgr.GetScheme(),
+	}
+}
+
+// add adds a new Controller to mgr with r as the reconcile.Reconciler
+func add(mgr manager.Manager, r reconcile.Reconciler) error {
+	// Create a new controller
+	c, err := controller.New("accessapplication-controller", mgr, controller.Options{Reconciler: r})
+	if err != nil {
+		return err
+	}
+
+	// Watch for changes to AccessApplication
+	err = c.Watch(&source.Kind{
+		Type: &crdsv1alpha1.AccessApplication{},
+	}, &handler.EnqueueRequestForObject{})
+	if err != nil {
+		return errors.Wrap(err, "failed to start watch on accessapplication")
+	}
+
+	generatedClient := kubernetes.NewForConfigOrDie(mgr.GetConfig())
+	generatedInformers := kubeinformers.NewSharedInformerFactory(generatedClient, time.Minute)
+	err = mgr.Add(manager.RunnableFunc(func(s <-chan struct{}) error {
+		generatedInformers.Start(s)
+		<-s
+		return nil
+	}))
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+var _ reconcile.Reconciler = &ReconcileAccessApplication{}
+
+// ReconcileAccessApplication reconciles a AccessApplication object
+type ReconcileAccessApplication struct {
+	client.Client
+	scheme *runtime.Scheme
+}
+
+// Reconcile reads that state of the cluster for a ReconcileAccessApplication object and makes changes based on the state read
+// and what is in the Zone.Spec
+// +kubebuilder:rbac:groups=crds.kubeflare.io,resources=accessapplications,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=crds.kubeflare.io,resources=accessapplications/status,verbs=get;update;patch
+func (r *ReconcileAccessApplication) Reconcile(request reconcile.Request) (reconcile.Result, error) {
+	// This reconcile loop will be called for all ReconcileAccessApplication objects
+	// because of the informer that we have set up
+	ctx := context.Background()
+	instance := crdsv1alpha1.AccessApplication{}
+	err := r.Get(ctx, request.NamespacedName, &instance)
+	if err != nil {
+		logger.Error(err)
+		return reconcile.Result{}, err
+	}
+
+	zone, err := shared.GetZone(ctx, instance.Namespace, instance.Spec.Zone)
+	if err != nil {
+		logger.Error(err)
+		return reconcile.Result{}, err
+	}
+
+	cf, err := shared.GetCloudflareAPI(ctx, instance.Namespace, zone.Spec.APIToken)
+	if err != nil {
+		logger.Error(err)
+		return reconcile.Result{}, err
+	}
+
+	// if the instanace status subresource doesn't contain an application id, update it now
+	if instance.Status.ApplicationID == "" {
+		existingApplication, err := findExistingAccessApplication(instance, zone, cf)
+		if err != nil {
+			logger.Error(err)
+			return reconcile.Result{}, nil
+		}
+
+		if existingApplication != nil {
+			instance.Status.ApplicationID = existingApplication.ID
+			err := r.Status().Update(ctx, &instance)
+			if err != nil {
+				logger.Error(err)
+				return reconcile.Result{}, nil
+			}
+		}
+	}
+
+	_, err = ReconcileAccessApplicationInstance(ctx, instance, zone, cf)
+	if err != nil {
+		logger.Error(err)
+		return reconcile.Result{}, err
+	}
+
+	return reconcile.Result{}, nil
+}
diff --git a/pkg/controller/accessapplication/cors_headers.go b/pkg/controller/accessapplication/cors_headers.go
new file mode 100644
index 0000000000000000000000000000000000000000..2b3eed7bc20f8be34a10098802a92ea9d13ee59c
--- /dev/null
+++ b/pkg/controller/accessapplication/cors_headers.go
@@ -0,0 +1,82 @@
+package accessapplication
+
+import (
+	"github.com/cloudflare/cloudflare-go"
+	"github.com/pkg/errors"
+	crdsv1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	"github.com/replicatedhq/kubeflare/pkg/controller/shared"
+)
+
+// diffCORSHeaders will diff existing to desired.
+// if there are diffs, it will return not-nil in the first response param
+func diffCORSHeaders(existing *cloudflare.AccessApplicationCorsHeaders, desired *crdsv1alpha1.CORSHeader) (*cloudflare.AccessApplicationCorsHeaders, error) {
+	if existing == nil && desired == nil {
+		return nil, nil
+	}
+
+	if existing == nil {
+		// copy all of the desired
+		updated := &cloudflare.AccessApplicationCorsHeaders{
+			AllowedMethods:   desired.AllowedMethods,
+			AllowedOrigins:   desired.AllowedOrigins,
+			AllowedHeaders:   desired.AllowedHeaders,
+			AllowAllMethods:  desired.AllowAllMethods,
+			AllowAllOrigins:  desired.AllowAllOrigins,
+			AllowAllHeaders:  desired.AllowAllHeaders,
+			AllowCredentials: desired.AllowCredentials,
+			MaxAge:           desired.MaxAge,
+		}
+
+		return updated, nil
+	}
+
+	if desired == nil {
+		// TODO, delete?
+		return nil, errors.New("deleting cors headers is not yet implemented")
+	}
+
+	wasUpdated := false
+	updated := cloudflare.AccessApplicationCorsHeaders{
+		AllowedMethods:   desired.AllowedMethods,
+		AllowedOrigins:   desired.AllowedOrigins,
+		AllowedHeaders:   desired.AllowedHeaders,
+		AllowAllMethods:  desired.AllowAllMethods,
+		AllowAllOrigins:  desired.AllowAllOrigins,
+		AllowAllHeaders:  desired.AllowAllHeaders,
+		AllowCredentials: desired.AllowCredentials,
+		MaxAge:           desired.MaxAge,
+	}
+
+	if !shared.StringSlicesMatch(existing.AllowedMethods, desired.AllowedMethods) {
+		wasUpdated = true
+	}
+	if !shared.StringSlicesMatch(existing.AllowedOrigins, desired.AllowedOrigins) {
+		wasUpdated = true
+	}
+
+	if !shared.StringSlicesMatch(existing.AllowedHeaders, desired.AllowedHeaders) {
+		wasUpdated = true
+	}
+
+	if existing.AllowAllMethods != desired.AllowAllMethods {
+		wasUpdated = true
+	}
+
+	if existing.AllowAllOrigins != desired.AllowAllOrigins {
+		wasUpdated = true
+	}
+
+	if existing.AllowAllHeaders != desired.AllowAllHeaders {
+		wasUpdated = true
+	}
+
+	if existing.MaxAge != desired.MaxAge {
+		wasUpdated = true
+	}
+
+	if !wasUpdated {
+		return nil, nil
+	}
+
+	return &updated, nil
+}
diff --git a/pkg/controller/accessapplication/cors_headers_test.go b/pkg/controller/accessapplication/cors_headers_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..00658a3e0114b2908232123e24f29b69c0e11047
--- /dev/null
+++ b/pkg/controller/accessapplication/cors_headers_test.go
@@ -0,0 +1,90 @@
+package accessapplication
+
+import (
+	"testing"
+
+	"github.com/cloudflare/cloudflare-go"
+	crdsv1alpha1 "github.com/replicatedhq/kubeflare/pkg/apis/crds/v1alpha1"
+	"github.com/stretchr/testify/require"
+	"gotest.tools/assert"
+)
+
+func Test_diffCORSHeaders(t *testing.T) {
+	tests := []struct {
+		name     string
+		existing *cloudflare.AccessApplicationCorsHeaders
+		desired  *crdsv1alpha1.CORSHeader
+		expected *cloudflare.AccessApplicationCorsHeaders
+	}{
+		{
+			name:     "both are nil",
+			existing: nil,
+			desired:  nil,
+			expected: nil,
+		},
+		{
+			name:     "no existing, has desired",
+			existing: nil,
+			desired: &crdsv1alpha1.CORSHeader{
+				AllowedMethods:   []string{"a"},
+				AllowedOrigins:   []string{"b", "c"},
+				AllowedHeaders:   []string{},
+				AllowAllMethods:  false,
+				AllowAllOrigins:  true,
+				AllowAllHeaders:  false,
+				AllowCredentials: true,
+				MaxAge:           9999,
+			},
+			expected: &cloudflare.AccessApplicationCorsHeaders{
+				AllowedMethods:   []string{"a"},
+				AllowedOrigins:   []string{"b", "c"},
+				AllowedHeaders:   []string{},
+				AllowAllMethods:  false,
+				AllowAllOrigins:  true,
+				AllowAllHeaders:  false,
+				AllowCredentials: true,
+				MaxAge:           9999,
+			},
+		},
+		{
+			name: "change 1 field",
+			existing: &cloudflare.AccessApplicationCorsHeaders{
+				AllowedMethods:   []string{"a"},
+				AllowedOrigins:   []string{"b", "c"},
+				AllowedHeaders:   []string{},
+				AllowAllMethods:  false,
+				AllowAllOrigins:  true,
+				AllowAllHeaders:  false,
+				AllowCredentials: true,
+				MaxAge:           9999,
+			},
+			desired: &crdsv1alpha1.CORSHeader{
+				AllowedMethods:   []string{"a"},
+				AllowedOrigins:   []string{"b", "c'"},
+				AllowedHeaders:   []string{},
+				AllowAllMethods:  false,
+				AllowAllOrigins:  true,
+				AllowAllHeaders:  false,
+				AllowCredentials: true,
+				MaxAge:           9999,
+			},
+			expected: &cloudflare.AccessApplicationCorsHeaders{
+				AllowedMethods:   []string{"a"},
+				AllowedOrigins:   []string{"b", "c'"},
+				AllowedHeaders:   []string{},
+				AllowAllMethods:  false,
+				AllowAllOrigins:  true,
+				AllowAllHeaders:  false,
+				AllowCredentials: true,
+				MaxAge:           9999,
+			},
+		},
+	}
+
+	for _, test := range tests {
+		req := require.New(t)
+		actual, err := diffCORSHeaders(test.existing, test.desired)
+		req.NoError(err)
+		assert.DeepEqual(t, test.expected, actual)
+	}
+}
diff --git a/pkg/controller/dnsrecord/dns_records.go b/pkg/controller/dnsrecord/dns_records.go
index a1352c90a08423d1d1c3bd706cc1234d70e44c6f..dc12f059950399d175a3e9826a105c3d75e46560 100644
--- a/pkg/controller/dnsrecord/dns_records.go
+++ b/pkg/controller/dnsrecord/dns_records.go
@@ -9,7 +9,7 @@ import (
 	"github.com/replicatedhq/kubeflare/pkg/logger"
 )
 
-func ReconcileDNSRecords(ctx context.Context, instance crdsv1alpha1.DNSRecord, zone *crdsv1alpha1.Zone, cf *cloudflare.API) error {
+func ReconcileDNSRecordInstances(ctx context.Context, instance crdsv1alpha1.DNSRecord, zone *crdsv1alpha1.Zone, cf *cloudflare.API) error {
 	logger.Debug("reconcileDNSRecords for zone")
 
 	zoneID, err := cf.ZoneIDByName(zone.Name)
diff --git a/pkg/controller/dnsrecord/dnsrecord_controller.go b/pkg/controller/dnsrecord/dnsrecord_controller.go
index 9e56ececa2156590256efa2c80b515b469240931..673a5978e226d53f85613491631127259efc854c 100644
--- a/pkg/controller/dnsrecord/dnsrecord_controller.go
+++ b/pkg/controller/dnsrecord/dnsrecord_controller.go
@@ -114,7 +114,7 @@ func (r *ReconcileDNSRecord) Reconcile(request reconcile.Request) (reconcile.Res
 		return reconcile.Result{}, err
 	}
 
-	if err := ReconcileDNSRecords(ctx, instance, zone, cf); err != nil {
+	if err := ReconcileDNSRecordInstances(ctx, instance, zone, cf); err != nil {
 		logger.Error(err)
 		return reconcile.Result{}, err
 	}
diff --git a/pkg/controller/shared/strings.go b/pkg/controller/shared/strings.go
new file mode 100644
index 0000000000000000000000000000000000000000..b16ea9fcd786c6e6bddcae89db612da2a7587d74
--- /dev/null
+++ b/pkg/controller/shared/strings.go
@@ -0,0 +1,47 @@
+package shared
+
+import "sort"
+
+func StringSlicesMatch(a []string, b []string) bool {
+	// sort on a copy to preserve original order
+	aa := make([]string, len(a))
+	bb := make([]string, len(b))
+
+	copy(aa, a)
+	copy(bb, b)
+
+	sort.Strings(aa)
+	sort.Strings(bb)
+
+	hasChanged := len(aa) != len(bb)
+
+	if !hasChanged {
+		for i, v := range aa {
+			if v != bb[i] {
+				hasChanged = true
+			}
+		}
+	}
+
+	return !hasChanged
+}
+
+func InterfaceArrayToStringArray(in []interface{}) []string {
+	out := make([]string, len(in))
+
+	for k, v := range in {
+		out[k] = v.(string)
+	}
+
+	return out
+}
+
+func StringArrayToInterfaceArray(in []string) []interface{} {
+	out := make([]interface{}, len(in))
+
+	for k, v := range in {
+		out[k] = v
+	}
+
+	return out
+}