Our Mobile Users Authentication Success Journey
This article is about our User Authentication journey, including the challenges and opportunities we’ve experienced, both technically and product-wise.
Introduction
Almost every mobile app has an authentication mechanism that allows users to log in or create new user accounts in order to access their own private experience.
In many apps, the authentication flow is the first step that users face, and in my opinion, it must be welcoming, easy, and clear – or you’ll lose them.
Before I start, please note that when I mention “authentication flow/mechanism,” I mean the process of a user logging in or signing up to the app.
My name is Nir Lachman, and I’ve been a Mobile Engineer (mostly iOS) for the past 10 years. Today, I’m a team member of the wonderful Mobile team at monday.com.
In this article, I’m going to present to you our User Authentication journey as a full feature cycle, including the challenges and opportunities we’ve experienced while working on it in our app, both technically and product-wise.
I’m going to start on the Product side of this feature along with the data that drove us to challenge the feature and how it worked before. Later on, I will explain how we broke things down to better plan and prepare before we started to work on an actual solution that will improve the feature. Lastly, I will present the technical challenges and our solution for them in the form of a StateMachine (aka State Pattern).
This is going to be a long journey, so relax and enjoy it 🙂
Product First
So, why did we want to change it anyway?
Bottom line – our user authentication mechanism was just not good enough for the variety of signup and login methods that are available today.
The first thing we did is look at the data. We looked at the success rate of our authentication flow for Signup (for new users) and Login (for existing users).
A quick glance showed:
We had many users who didn’t complete our authentication flow – or the other way around, we had many potential new users to bring to monday.com. Also, we had a huge drop right at the beginning of the flow!
In addition, we also saw that out of the users who completed the flow, many of them only completed it after trying to do something else first (i.e., 15% tried to log in but clicked sign up, managing to eventually log in but failing at what they initially wanted to accomplish).
Crazy, isn’t it?
We broke things down to better understand what we need to succeed.
The goals
Before we started to dive into the work, we first had to define the goal we wanted to reach. Our high KPI (Key Performance Indicator), of course, was to increase the Mobile Weekly Active Users, and our smaller proxy KPI was to increase the user authentication success rate by 25%.
We knew that it was a tough goal, but we set it in order to “dream bigger” 🙂
What challenges did we face?
The user authentication mechanism on monday.com stores many complexities behind the scenes.
Users can sign up or log in with email and password, using SSO (Single sign-on) providers such as Google, LinkedIn, Slack, OKTA, and even more providers that implement custom SAML.
Our iOS app had a legacy user authentication flow which raised a few challenges:
- The code was too complex and didn’t comply with our conventions(*), just a spaghetti ???? code with one ViewModel that was used for all the ViewControllers.
We use the MVVM pattern, which basically separates the View from the data and the business logic. A View uses a ViewModel that handles all the logic and just feeds the View with the “formatted” data so the view can just present it. Using MVVM brings a huge benefit to the table, such as allowing better reusability, testability, and of course, it complying with SOLID principles.
So why did we have to build this feature from the ground up?
We had one ViewModel that aggregated all the data and held the logic for the entire flow, for all the ViewControllers with the same object instance – impossible to understand, change, or add anything without breaking something!
* Our pattern conventions, in short, we use a pattern architecture of an extended MVVM:
View → ViewModel → Service (with Networking, DB, etc…)
- The UX of the screens was very misleading and unclear. It did not have error views and was very hard to understand.
For example, the ‘next’ button on each screen was not visible enough.
Users didn’t know at what stage they were at and what information we were asking them to provide.
In addition, the UI was out-dated and not aligned with the UI of the rest of the app, which made it look really bad and not “monday.com.” In addition, we didn’t focus the users on important things like the UITextField inputs. - We had no monitoring mechanisms or analytics of the flow – we were clueless as to any issues and exact drops.
- Due to the problematic codebase, which was really dependent on the one large ViewModel (see my first point), it was almost impossible to run A/B tests in this area – no A/B test = no option to improve and check new hypotheses.
The old UI:
What’s the focus here? How to move to the next step?
The context is unclear. ????????♂️
Preparation ????????
Preparation is everything when you approach this type of task. Preparation will make things run faster and smoother in the long run and help you avoid bumps down the road.
Our preparation here was divided into two main parts
Product research
- We did internal and external research with users to better understand what works for them.
- We broke down all the flows we wanted and defined the possible paths users can go down to log in or sign up. To do this, we create a giant visual diagram that we used as a source-of-truth.
- We saw that we have a large drop in the funnel (meaning that we lost users) in the intro screen, where users need to choose between: ‘Create a new account’ and ‘Log In’. To improve this drop, we decided to transfer the users to the correct state based on what we know about their email input. For example:
A user with a new non-existing email will automatically be transferred by the app to the Sign-Up screen (Create a new account). - We created a new UI/UX, based on building blocks (reusable components), including error handling, to ensure all the screens feel and look the same. We found that one of the most helpful things for users in the authentication flow is to let them know at what stage they’re at and how many stages are ahead of them, so we added progress indication in each screen (simple but useful!).
Old flow screens
Unclear context and too many distractions
New flow screens
Focus on what’s important
Technical design
- We wanted to develop a mechanism that is robust and flexible enough to allow us to change the flows between the screens easily.
For example: Moving the User Details Screen to be the last screen instead of the 2nd screen in the signup flow. Sounds simple but what about all the relevant data? Who is in charge to make this switch?
In addition, we wanted the feature to be highly monitored using analytics and logs so we can easily detect errors or any drops in the funnels. - The goal was to build the right architecture to support the separation of concerns design principle, highly open to changes (mainly to support A/B tests and future flow changes) and allows us high reusability of code (more information about in down below :))
- Based on what we designed for the UI, we created a building block infra UI that is used for the whole flow, such as custom UITextFields or custom UIButtons for the user inputs. Using these components for the whole authentication flow, in all the Views, makes it super easy to make UI and UX changes in one place and keep all the screens in “the same (UI) language.”
In my opinion, thinking of the technical solutions without thinking about the product (the users) just doesn’t work – they have to be planned hand in hand.
Dev Stuff
Let’s dive into some technical details of the architecture
Up until now, I presented the background and the product side of our journey. In the next part of the article, I’m going to dive into some technical stuff, such as the architecture we used that worked best for us.
State Machine for the win ????
Choosing and forming the “right” architecture/design pattern is crucial. You have to use what works best for you and also can be integrated into your current project and conventions (at least if you already have something that works :)).
We chose to go with the classic architecture of “State Machine aka State Pattern” and adjust it to our needs and current conventions as we believe in it and that it can work well for us.
In the “classic” State Machine, each state is responsible for deciding which state goes next.
In our own StateMachine format, we wanted something a little bit different. We wanted a machine that determines where to go next, instead of letting each state determine what’s the next state/step.
The StateMachine should predefine allowed *transitions and be responsible for making them. It is also responsible for preventing “illegal” transitions (like those that are not defined).
At this time, we went with predefined transitions that we use locally (from the code), but the mechanism can easily be used with dynamic transitions from a remote server.
* State Transition from one state to another. For example:
Moving from ‘User Email’ state to ‘User Personal Details’ state.
We defined a Transition by the following tuple:
(From State, State Event) -> State
To understand our custom solution and the full flow, see the full diagram and its components below:
What is this all about?
A – StateMachine – A generic component (as stated above) that can be used with any kind of data and states. How does it work? Pretty simple, when a new state event is sent to the machine, it checks if the current state combined with the received event has any defined transition – if so, it makes the transition to the new state, or else throws an error.
It is responsible for:
- Making the decision if a transition is allowed or not.
- If a transition is allowed – make the transition, which basically means that the next state will be the current state.
- This component also supports reverse/undo and move back to the exact previous state (and data).
B – AuthStateMachine – The heart of the whole authentication mechanism. Sort of a concrete case of the generic StateMachine. This component uses the StateMachine to handle all the relevant data and states of the authentication flow. AuthStateMachine also uses a data stack which is filled by each step output data (more on that later).
C – AuthDataProvider -This component is responsible for handling the aggregated data (including update and remove in case of reverse). It uses a Data Stack of AggregatedData object.
In the authentication mechanism, we needed an object that aggregates that data input that the users add in each step. For example, in the first step, users enter their email addresses, and later they enter their password or any other personal information.
The data is provided by using input and output data for and from each step.
D – AuthTracker – A component that tracks all events and errors using our in-house analytics system provided by the wonderful Big Brain team.
E – AuthPresenter – A component that is responsible only for the ViewController presentation. It uses a navigation controller to present a step/state.
F – StepFactory – A component that creates a new Step that correlates with the State.
G – Step – Each State is wrapped/represented by a Step. A Step can be any object you need. In our case, we use steps as objects that are responsible for creating a ViewController with ViewModel.
Each step has Input data and Output Data which is relevant specifically to it.
For example, a state of ‘UserPersonDetail’ is represented by a Step called ‘UserPersonalDetailStep’ which is just an object that creates the relevant ViewController with its relevant ViewModel. It has an input of email: String and output of details: UserDetail and a StateEvent.
H – Each step will send a StateEvent, which will eventually be handled by the StateMachine.
Go with the flow
Now that we understand each component, let’s go over this flow diagram to understand how it actually works! Let’s take a discover at the numbers in the diagram:
- To start, AuthStateMachine defines transitions that it wants to support. For example:
let transition = StateMachineTransition(fromState: .initial,
event: .startAuth,
toState: .loginUserEmail)
machine.addTransition(transition)
….
Here we create a new transition from state ‘initial’ (in our case it’s the first) with event ‘startLogin’ which will allow state change go ‘loginUserEmail’
- AuthStateMachine sends the first event (aka StateEvent) to the StateMachine. For example, ‘startAuth’
machine.sendEvent(.startAuth)
- StateMachine validates that it has an allowed transition (from a state + event to a new state) and invokes its delegate methods call – succeed or fail (more on this code in the next section). In addition, it pushes the new state to the States Stack. (Remember: we use this stack to be able to reverse/undo the states and also track the whole flow).
- StateMachineDelegate passes the method call to the implementer, which is AuthStateMachine, in our case.
delegate?.transitionDidFinish(for: transition)
- AuthStateMachine receives the delegate call and prepares to handle the next state by creating a new Step (to represent the State):
func transitionDidFinish(for transition: StateMachineTransition<AuthState, AuthStateEvent>) {
let step = nextStep(for: transition.toState)
show(step: step)
}
- Step & StepFactory –
Step is an object that creates a hold input and out data, and when it needs to be shown, it creates a ViewController and its relevant ViewModel and services.
protocol StateMachineStep {
associatedtype Input
associatedtype Output
var input: Input? { get }
@discardableResult func show(from navigation: UINavigationController) -> Observable<Output>
}
StepFactory just creates a new Step based on a state:
func step(for state: AuthState, data: AuthData) -> BaseAuthStep? {
switch state {
case .loginUserEmail:
return LoginUserEmailStep(tracker: tracker)
...
}
}
- When the step is finished, it returns an output with data and events. (See the previous section, component Step).
let event: AuthStateEvent = .authWithPasswordOnly
let data: AuthDataOutput = .loginUserEmail(email: email, accountDetailsResponse)
return (event, data)
- Step output (data and event) passes to the AuthStateMachine, which pushes the new data to the Data Stack.
dataProvider.updateData(for: output.data)
- AuthStateMachine passes the event to the StateMachine.
machine.sendEvent(output.event)
- The loop starts over again – just as it happens from stage 2.
The Code
Let’s take a look at some more detailed code!
StateMachineTransition
Let’s start with a definition of Transition. Transitions are based on generic State and Events.
Using generic types here allows us full flexibility and reusability. In our case, we defined them as Enums String, but they can be any Hashable object (at least Equatable to be able to save them in a dictionary).
struct StateMachineTransition<State: Hashable, Event: Hashable> {
let fromState: State
let event: Event
let toState: State
}
For example:
StateTransitionTrigger
This object is the combination of the state and event which is used as a unique key to map the allowed transitions (StateTransitionTrigger –> State)
struct StateTransitionTrigger<State: Hashable, Event: Hashable>: Hashable {
let fromState: State
let event: Event
}
StateMachineDelegate
We use this delegate to notify any object which uses the state machine for transition changes.
protocol StateMachineDelegate: class {
associatedtype State: Hashable
associatedtype Event: Hashable
func transitionDidFinish(for transition: StateMachineTransition<State, Event>)
func transitionDidFail(for transitionTrigger: StateTransitionTrigger<State, Event>)
}
StateMachine
class StateMachine<State, Event, Delegate> where Delegate: StateMachineDelegate, Delegate.State == State, Delegate.Event == Event {
// 1
typealias TransitionTrigger = StateTransitionTrigger<State, Event>
typealias Transition = StateMachineTransition<State, Event>
typealias TransitionsMap = [TransitionTrigger: State] // (fromState + event) : toState
private var currentState: State {
get {
self.statesFlow.peek()!
}
set {
self.statesFlow.push(newValue)
}
}
weak var delegate: Delegate?
var statesFlow = Stack<State>()
private var allowedTransitions = TransitionsMap()
required init(state: State) {
self.statesFlow = Stack<State>(items: [state])
}
// 2
func sendEvent(_ event: Event) {
let transitionTrigger = TransitionTrigger(fromState: currentState,
event: event)
// 3
guard let transition = getTransition(for: transitionTrigger), transition.toState != currentState else {
delegate?.transitionDidFail(for: transitionTrigger)
return
}
currentState = transition.toState
delegate?.transitionDidFinish(for: transition)
}
// 4
func addTransition(_ transition: StateMachineTransition<State, Event>) {
let transitionTrigger = TransitionTrigger(fromState: transition.fromState,
event: transition.event)
allowedTransitions[transitionTrigger] = transition.toState
}
// 5
func reverseState() {
statesFlow.pop()
}
....
}
That’s a lot of code – let’s break it down:
- We define our transitions and the transition map (aka allowed transitions). In addition, we initialize StatesStack.
I really like typealias because it can make the code much easier to read and understand, even though the underlying code may be a longer definition. - sendEvent method is responsible for handling events from the component that uses the StateMachine. Its logic is pretty simple – create a new trigger based on the current state and the received event and then check if there’s a valid transition.
- Try to get an allowed transition from the map based on a transitionTrigger.
Remember, the transition trigger is the unique key to our transitionsMap. - addTransition method is responsible for adding a new transition to the allowed transitionsMap. Again, we create transitionTrigger and add it to the map.
- reverseState just pops the state from the stack. There are few reasons to reverse a state – one of them is that the user went back to the previous screen (State).
AuthStateMachine
class AuthStateMachine: AuthStateMechanics {
// 1
typealias Step = BaseAuthStep
typealias State = AuthState
typealias Event = AuthStateEvent
typealias StateTransitionInfo = StateTransitionTrigger<State, Event>
private let stepPresenter: AuthStepPresenting
private let machine: StateMachine<State, Event, AuthStateMachine>
private let dataProvider: AuthDataProviding
private let tracker: AuthTracker
private lazy var stepsFactory = AuthStepFactory(tracker: self.tracker)
// MARK: - Init
init(stepPresenter: AuthStepPresenting,
dataProvider: AuthDataProviding = AuthDataProvider(),
machine: StateMachine<State, Event, AuthStateMachine> = StateMachine(state: .initial),
tracker: AuthTracker) {
self.stepPresenter = stepPresenter
self.dataProvider = dataProvider
self.machine = machine
self.tracker = tracker
setupTransitions()
// 2
machine.delegate = self
startAuth()
}
// 3
func startAuth() {
machine.sendEvent(.startAuth)
}
// 4
func setupTransitions() {
// Intro
machine.addTransition(StateMachineTransition(fromState: .introUserEmail, event: .canLogin, toState: .loginIntro))
machine.addTransition(StateMachineTransition(fromState: .introUserEmail, event: .canJoinAccount, toState: .joinIntro))
....
}
// 5
func nextStep(for state: State) -> Step? {
return stepsFactory.step(for: state, data: dataProvider.data)
}
// 6
func show(step: Step) {
stepPresenter.show(step: step)
.subscribeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] output in
self?.handleOutput(output)
}, onError: { [weak self] _ in
self?.reverse()
})
.disposed(by: disposeBag)
}
// 7
private func handleOutput(_ output: AuthOutput) {
let data = output.data
let event = output.event
// Update the aggregated data with the new data from the output
dataProvider.updateData(for: data)
// Send the next event to State Machine
machine.sendEvent(event)
}
// 8
private func reverse() {
machine.reverseState()
dataProvider.reverse()
}
}
extension AuthStateMachine: StateMachineDelegate {
// 9
func transitionDidFinish(for transition: StateMachineTransition<AuthState, AuthStateEvent>) {
guard let step = nextStep(for: transition.toState) else { ... }
show(step: step)
}
// 10
func transitionDidFail(for transitionTrigger: StateTransitionTrigger<State, Event>) {
Logger.debug("transition failed for \(transitionTrigger)")
....
}
}
Let’s break this one down as well:
- Prepare all the relevant components, such as StateMachine, DataProvider, and Tracker.
- Set the AuthStateMachine as the StateMachineDelegate implementer.
- startAuth is a convenience method to start the flow, which basically means that we send an event to the StateMachine.
- setupTransitions is where we pre-defined the allowed transition. Here I present just a local hardcoded definition, but it can easily be done by getting data from a remote server and then adding the transitions.
- Another convenience method just uses the StepFactory to create a new Step instance based on a received State.
- Show the next step (which represents a state) and observe its output. This is a custom implementation that we did to fit our needs. However, it can be totally different if required to meet other needs.
- When an output event is triggered from a step, handle this output by updating the data and sending the next event (which is also part of the output). This is truly where our adjustments are different from the classic State Pattern.
- Reverse both StateMachine and the aggregated data to be rolled back exactly to the previous state.
- StateMachineDelegate method, which calls when a transition has finished – we decide in our AuthStateMachine to prepare and show the next Step.
- StateMachineDelegate method which calls when a transition has failed.
The Results ????
So how did all this work for us? The results are amazing! ????
You are probably wondering how it ends. So, here it is:
We improved our Signup flow (for new users) success rate by 20% ‼️ and our Login flow success rate by 5% ❗️ (you can do the math on how many new users we’ve added 🙂 ).
What’s next?
We will keep examining the existing flows and monitor the funnels (which we couldn’t do in the past). We’re going to conduct more A/B tests in this area – until we have something we’re completely satisfied with :)For now, we’re already reaping the fruits of our labor! The User Authentication flow is much smoother, cleaner, easier to maintain, and, most importantly – easily open to changes.
In addition, we’ve already conducted 3 A/B tests, which as I mentioned above, was an almost impossible task to do in the past.
Conclusion
This was a long ride – thanks for reading this far!
In this article, I presented the full cycle of the new user authentication flow we created, including preparations, both Product and Dev wise, and execution.
Key takeaways from this article
- When you want to examine an area/feature/component to improve or just to understand its performance – use user data (analytics). At monday.com, we always base our plans and decisions on data.
- Before you start, set your goals (KPIs), so you can measure your success.
- Preparation and research both on the product and the technical side is a crucial step to succeed, or at least to increase your success. Remember, they go hand in hand.
- Choose a suitable technical design pattern (or solution) that works best for you and for your needs.
- Data monitoring should be an essential part of your code and features. Without it, you will not be able to measure performance and of course to fix/improve.
- StateMachine can be really useful for continuous flows and for any flow that needs to change some step behaviors based on a new state.
One more thing! ????
We’re hiring! We’re looking for awesome people and Mobile Engineers who want to take ownership of our next challenges – and we have a lot!
You can check out our career page here:
https://monday.com/careers/C2.100
Download monday.com app from the AppStore!
Feel free to contact me via LinkedIn or any other way that works for you 🙂