diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
index 25a2665ec98ee772c5ac68abc53d22aef7eb2850..e5d4cdad2c79da150a5eafdd35797345b66a735f 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
@@ -82,7 +82,8 @@ import org.bigbluebutton.api2.IBbbWebApiGWApp;
 import org.bigbluebutton.api2.domain.UploadedTrack;
 import org.bigbluebutton.common2.redis.RedisStorageService;
 import org.bigbluebutton.presentation.PresentationUrlDownloadService;
-import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
+import org.bigbluebutton.web.services.UserCleanupTimerTask;
+import org.bigbluebutton.web.services.EnteredUserCleanupTimerTask;
 import org.bigbluebutton.web.services.callback.CallbackUrlService;
 import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
 import org.bigbluebutton.web.services.turn.StunTurnService;
@@ -107,12 +108,16 @@ public class MeetingService implements MessageListener {
   private final ConcurrentMap<String, UserSession> sessions;
 
   private RecordingService recordingService;
-  private RegisteredUserCleanupTimerTask registeredUserCleaner;
+  private UserCleanupTimerTask userCleaner;
+  private EnteredUserCleanupTimerTask enteredUserCleaner;
   private StunTurnService stunTurnService;
   private RedisStorageService storeService;
   private CallbackUrlService callbackUrlService;
   private boolean keepEvents;
 
+  private long usersTimeout;
+  private long enteredUsersTimeout;
+
   private ParamsProcessorUtil paramsProcessorUtil;
   private PresentationUrlDownloadService presDownloadService;
 
@@ -179,22 +184,63 @@ public class MeetingService implements MessageListener {
   }
 
   /**
-   * Remove registered users who did not successfully joined the meeting.
+   * Remove users who did not successfully reconnected to the meeting.
    */
-  public void purgeRegisteredUsers() {
+  public void purgeUsers() {
     for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
       Long now = System.currentTimeMillis();
       Meeting meeting = entry.getValue();
 
-      ConcurrentMap<String, User> users = meeting.getUsersMap();
+      for (AbstractMap.Entry<String, User> userEntry : meeting.getUsersMap().entrySet()) {
+        String userId = userEntry.getKey();
+        User user = userEntry.getValue();
+
+        if (!user.hasLeft()) continue;
+
+        long elapsedTime = now - user.getLeftOn();
+        if (elapsedTime >= usersTimeout) {
+          meeting.removeUser(userId);
+
+          Map<String, Object> logData = new HashMap<>();
+          logData.put("meetingId", meeting.getInternalId());
+          logData.put("userId", userId);
+          logData.put("logCode", "removed_user");
+          logData.put("description", "User left and was removed from the meeting.");
 
-      for (AbstractMap.Entry<String, RegisteredUser> registeredUser : meeting.getRegisteredUsers().entrySet()) {
-        String registeredUserID = registeredUser.getKey();
-        RegisteredUser registeredUserDate = registeredUser.getValue();
+          Gson gson = new Gson();
+          String logStr = gson.toJson(logData);
 
-        long elapsedTime = now - registeredUserDate.registeredOn;
-        if (elapsedTime >= 60000 && !users.containsKey(registeredUserID)) {
-          meeting.userUnregistered(registeredUserID);
+          log.info(" --analytics-- data={}", logStr);
+        }
+      }
+    }
+  }
+
+  /**
+   * Remove entered users who did not join.
+   */
+  public void purgeEnteredUsers() {
+    for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
+      Long now = System.currentTimeMillis();
+      Meeting meeting = entry.getValue();
+
+      for (AbstractMap.Entry<String, Long> enteredUser : meeting.getEnteredUsers().entrySet()) {
+        String userId = enteredUser.getKey();
+
+        long elapsedTime = now - enteredUser.getValue();
+        if (elapsedTime >= enteredUsersTimeout) {
+          meeting.removeEnteredUser(userId);
+
+          Map<String, Object> logData = new HashMap<>();
+          logData.put("meetingId", meeting.getInternalId());
+          logData.put("userId", userId);
+          logData.put("logCode", "purged_entered_user");
+          logData.put("description", "Purged user that called ENTER from the API but never joined");
+
+          Gson gson = new Gson();
+          String logStr = gson.toJson(logData);
+
+          log.info(" --analytics-- data={}", logStr);
         }
       }
     }
@@ -817,13 +863,6 @@ public class MeetingService implements MessageListener {
           // the meeting ended.
           m.setEndTime(System.currentTimeMillis());
         }
-
-        RegisteredUser userRegistered = m.userUnregistered(message.userId);
-        if (userRegistered != null) {
-          log.info("User unregistered from meeting");
-        } else {
-          log.info("User was not unregistered from meeting because it was not found");
-        }
       }
     }
   }
@@ -1036,7 +1075,8 @@ public class MeetingService implements MessageListener {
 
   public void stop() {
     processMessage = false;
-    registeredUserCleaner.stop();
+    userCleaner.stop();
+    enteredUserCleaner.stop();
   }
 
   public void setRecordingService(RecordingService s) {
@@ -1055,12 +1095,16 @@ public class MeetingService implements MessageListener {
     this.gw = gw;
   }
 
+  public void setEnteredUserCleanupTimerTask(EnteredUserCleanupTimerTask c) {
+    enteredUserCleaner = c;
+    enteredUserCleaner.setMeetingService(this);
+    enteredUserCleaner.start();
+  }
 
-  public void setRegisteredUserCleanupTimerTask(
-    RegisteredUserCleanupTimerTask c) {
-    registeredUserCleaner = c;
-    registeredUserCleaner.setMeetingService(this);
-    registeredUserCleaner.start();
+  public void setUserCleanupTimerTask(UserCleanupTimerTask c) {
+    userCleaner = c;
+    userCleaner.setMeetingService(this);
+    userCleaner.start();
   }
 
   public void setStunTurnService(StunTurnService s) {
@@ -1070,4 +1114,12 @@ public class MeetingService implements MessageListener {
   public void setKeepEvents(boolean value) {
     keepEvents = value;
   }
+
+  public void setUsersTimeout(long value) {
+    usersTimeout = value;
+  }
+
+  public void setEnteredUsersTimeout(long value) {
+    enteredUsersTimeout = value;
+  }
 }
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java
index 4ee757a942efe30a3548f3260c6ac74114000ae2..a19b62d7732fa8201b6692d43439f20d3804fd13 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java
@@ -73,6 +73,7 @@ public class Meeting {
 	private Map<String, Object> userCustomData;
 	private final ConcurrentMap<String, User> users;
 	private final ConcurrentMap<String, RegisteredUser> registeredUsers;
+	private final ConcurrentMap<String, Long> enteredUsers;
 	private final ConcurrentMap<String, Config> configs;
 	private final Boolean isBreakout;
 	private final List<String> breakoutRooms = new ArrayList<>();
@@ -134,6 +135,7 @@ public class Meeting {
 
         users = new ConcurrentHashMap<>();
         registeredUsers = new ConcurrentHashMap<>();
+        enteredUsers = new  ConcurrentHashMap<>();;
 
         configs = new ConcurrentHashMap<>();
     }
@@ -449,12 +451,28 @@ public class Meeting {
 	}
 
 	public void userJoined(User user) {
-	    userHasJoined = true;
-	    this.users.put(user.getInternalUserId(), user);
+		User u = getUserById(user.getInternalUserId());
+		if (u != null) {
+			u.joined();
+		} else {
+			if (!userHasJoined) userHasJoined = true;
+			this.users.put(user.getInternalUserId(), user);
+			// Clean this user up from the entered user's list
+			removeEnteredUser(user.getInternalUserId());
+		}
+	}
+
+	public User userLeft(String userId) {
+		User user = getUserById(userId);
+		if (user != null) {
+			user.left();
+		}
+
+		return user;
 	}
 
-	public User userLeft(String userid){
-		return users.remove(userid);	
+	public User removeUser(String userId) {
+		return this.users.remove(userId);
 	}
 
 	public User getUserById(String id){
@@ -604,6 +622,29 @@ public class Meeting {
         return registeredUsers;
     }
 
+    public ConcurrentMap<String, Long> getEnteredUsers() {
+        return this.enteredUsers;
+    }
+
+    public void userEntered(String userId) {
+        // Skip if user already joined
+        User u = getUserById(userId);
+        if (u != null) return;
+
+        if (!enteredUsers.containsKey(userId)) {
+            Long time = System.currentTimeMillis();
+            this.enteredUsers.put(userId, time);
+        }
+    }
+
+    public Long removeEnteredUser(String userId) {
+        return this.enteredUsers.remove(userId);
+    }
+
+    public Long getEnteredUserById(String userId) {
+        return this.enteredUsers.get(userId);
+    }
+
     /***
 	 * Meeting Builder
 	 *
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java
index abd32ff6ca67535aae3eff212e6c6acc924c0d26..59521552c3041b30ef9e527d1635b3d614919fd8 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java
@@ -38,6 +38,7 @@ public class User {
 	private Boolean voiceJoined = false;
 	private String clientType;
 	private List<String> streams;
+	private Long leftOn = null;
 
 	public User(String internalUserId,
 							String externalUserId,
@@ -90,6 +91,22 @@ public class User {
 		return this.guestStatus;
 	}
 	
+	public Boolean hasLeft() {
+		return leftOn != null;
+	}
+
+	public void joined() {
+		this.leftOn = null;
+	}
+
+	public void left() {
+		this.leftOn = System.currentTimeMillis();
+	}
+
+	public Long getLeftOn() {
+		return this.leftOn;
+	}
+
 	public String getFullname() {
 		return fullname;
 	}
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/web/services/RegisteredUserCleanupTimerTask.java b/bbb-common-web/src/main/java/org/bigbluebutton/web/services/EnteredUserCleanupTimerTask.java
similarity index 89%
rename from bbb-common-web/src/main/java/org/bigbluebutton/web/services/RegisteredUserCleanupTimerTask.java
rename to bbb-common-web/src/main/java/org/bigbluebutton/web/services/EnteredUserCleanupTimerTask.java
index e4e0844572ad90a2d9a6fa4702d3b21e02656b3c..7ee01ea6ef9ea2c0ec786694d7a0a059bb63f817 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/web/services/RegisteredUserCleanupTimerTask.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/web/services/EnteredUserCleanupTimerTask.java
@@ -1,7 +1,7 @@
 /**
 * BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
 *
-* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
+* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
 *
 * This program is free software; you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free Software
@@ -25,11 +25,11 @@ import java.util.concurrent.TimeUnit;
 
 import org.bigbluebutton.api.MeetingService;
 
-public class RegisteredUserCleanupTimerTask {
+public class EnteredUserCleanupTimerTask {
 
     private MeetingService service;
     private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
-    private long runEvery = 60000;
+    private long runEvery = 30000;
 
     public void setMeetingService(MeetingService svc) {
         this.service = svc;
@@ -50,7 +50,7 @@ public class RegisteredUserCleanupTimerTask {
     private class CleanupTask implements Runnable {
         @Override
         public void run() {
-            service.purgeRegisteredUsers();
+            service.purgeEnteredUsers();
         }
     }
-}
\ No newline at end of file
+}
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/web/services/UserCleanupTimerTask.java b/bbb-common-web/src/main/java/org/bigbluebutton/web/services/UserCleanupTimerTask.java
new file mode 100755
index 0000000000000000000000000000000000000000..b11fab3f81cfdcfd31999b19f05fe34e95dea0f1
--- /dev/null
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/web/services/UserCleanupTimerTask.java
@@ -0,0 +1,56 @@
+/**
+* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
+*
+* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
+*
+* This program is free software; you can redistribute it and/or modify it under the
+* terms of the GNU Lesser General Public License as published by the Free Software
+* Foundation; either version 3.0 of the License, or (at your option) any later
+* version.
+*
+* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+*
+* You should have received a copy of the GNU Lesser General Public License along
+* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
+*
+*/
+
+package org.bigbluebutton.web.services;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.bigbluebutton.api.MeetingService;
+
+public class UserCleanupTimerTask {
+
+    private MeetingService service;
+    private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
+    private long runEvery = 15000;
+
+    public void setMeetingService(MeetingService svc) {
+        this.service = svc;
+    }
+
+    public void start() {
+        scheduledThreadPool.scheduleWithFixedDelay(new CleanupTask(), 60000, runEvery, TimeUnit.MILLISECONDS);
+    }
+
+    public void stop() {
+        scheduledThreadPool.shutdownNow();
+    }
+
+    public void setRunEvery(long v) {
+        runEvery = v;
+    }
+
+    private class CleanupTask implements Runnable {
+        @Override
+        public void run() {
+            service.purgeUsers();
+        }
+    }
+}
diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
index cb809d0910dbf6f4badf83f4ad0f2955c0bbb53d..85bf953de58ff6a9b6f612b575fa93bf36387fff 100755
--- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
+++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
@@ -215,6 +215,16 @@ allowModsToUnmuteUsers=false
 # Saves meeting events even if the meeting is not recorded
 keepEvents=false
 
+# Timeout (millis) to remove a joined user after her/his left event without a rejoin
+# e.g. regular user left event
+# Default 60s
+usersTimeout=60000
+
+# Timeout (millis) to remove users that called the enter API but did not join
+# e.g. user's client hanged between the enter call and join event
+# Default 45s
+enteredUsersTimeout=45000
+
 #----------------------------------------------------
 # This URL is where the BBB client is accessible. When a user sucessfully
 # enters a name and password, she is redirected here to load the client.
diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml
index 79b90ec521bac1af0f99f4b4181007d7d3ccf4ec..b543e378abfbf4dae3198e8ef4c8496fa180b141 100755
--- a/bigbluebutton-web/grails-app/conf/spring/resources.xml
+++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml
@@ -33,7 +33,8 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
         </property>
     </bean>
 
-    <bean id="registeredUserCleanupTimerTask" class="org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask"/>
+    <bean id="userCleanupTimerTask" class="org.bigbluebutton.web.services.UserCleanupTimerTask"/>
+    <bean id="enteredUserCleanupTimerTask" class="org.bigbluebutton.web.services.EnteredUserCleanupTimerTask"/>
 
     <bean id="keepAliveService" class="org.bigbluebutton.web.services.KeepAliveService"
           init-method="start" destroy-method="stop">
@@ -47,10 +48,13 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
         <property name="presDownloadService" ref="presDownloadService"/>
         <property name="paramsProcessorUtil" ref="paramsProcessorUtil"/>
         <property name="stunTurnService" ref="stunTurnService"/>
-        <property name="registeredUserCleanupTimerTask" ref="registeredUserCleanupTimerTask"/>
+        <property name="userCleanupTimerTask" ref="userCleanupTimerTask"/>
+        <property name="enteredUserCleanupTimerTask" ref="enteredUserCleanupTimerTask"/>
         <property name="gw" ref="bbbWebApiGWApp"/>
         <property name="callbackUrlService" ref="callbackUrlService"/>
         <property name="keepEvents" value="${keepEvents}"/>
+        <property name="usersTimeout" value="${usersTimeout}"/>
+        <property name="enteredUsersTimeout" value="${enteredUsersTimeout}"/>
     </bean>
 
     <bean id="oldMessageReceivedGW" class="org.bigbluebutton.api2.bus.OldMessageReceivedGW">
diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy
index c31b826c833ea7333ed8f6a47089e1aa35b0bd0f..5b98e5d5ffc5dc0a8704ba842fd85a06c9d34d72 100755
--- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy
+++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy
@@ -431,14 +431,9 @@ class ApiController {
       us.avatarURL = meeting.defaultAvatarURL
     }
 
-    // Register user into the meeting.
-    meetingService.registerUser(us.meetingID, us.internalUserId, us.fullname, us.role, us.externUserID,
-        us.authToken, us.avatarURL, us.guest, us.authed, guestStatusVal)
+    String meetingId = meeting.getInternalId()
 
-    // Validate if the maxParticipants limit has been reached based on registeredUsers. If so, complain.
-    // when maxUsers is set to 0, the validation is ignored
-    int maxUsers = meeting.getMaxUsers();
-    if (maxUsers > 0 && meeting.getRegisteredUsers().size() >= maxUsers) {
+    if (hasReachedMaxParticipants(meeting, us)) {
       // BEGIN - backward compatibility
       invalid("maxParticipantsReached", "The number of participants allowed for this meeting has been reached.", REDIRECT_RESPONSE);
       return
@@ -449,6 +444,20 @@ class ApiController {
       return;
     }
 
+    // Register user into the meeting.
+    meetingService.registerUser(
+        us.meetingID,
+        us.internalUserId,
+        us.fullname,
+        us.role,
+        us.externUserID,
+        us.authToken,
+        us.avatarURL,
+        us.guest,
+        us.authed,
+        guestStatusVal
+    )
+
     //Identify which of these to logs should be used. sessionToken or user-token
     log.info("Session sessionToken for " + us.fullname + " [" + session[sessionToken] + "]")
     log.info("Session user-token for " + us.fullname + " [" + session['user-token'] + "]")
@@ -1170,24 +1179,8 @@ class ApiController {
 
     String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
     boolean reject = false
-    String sessionToken = null
-    UserSession us = null
-
-    if (StringUtils.isEmpty(params.sessionToken)) {
-      log.info("No session for user in conference.")
-      reject = true
-    } else {
-      sessionToken = StringUtils.strip(params.sessionToken)
-      log.info("Getting ConfigXml for SessionToken = " + sessionToken)
-      if (!session[sessionToken]) {
-        reject = true
-      } else {
-        us = meetingService.getUserSessionWithAuthToken(sessionToken);
-        if (us == null) reject = true
-      }
-    }
-
-    if (reject) {
+    String sessionToken = sanitizeSessionToken(params.sessionToken)
+    if (!hasValidSession(sessionToken)) {
       response.addHeader("Cache-Control", "no-cache")
       withFormat {
         xml {
@@ -1195,6 +1188,7 @@ class ApiController {
         }
       }
     } else {
+      UserSession us = getUserSession(sessionToken)
       if (StringUtils.isEmpty(us.configXML)) {
         // BEGIN - backward compatibility
         invalid("noConfigFound", "We could not find a config for this request.", REDIRECT_RESPONSE);
@@ -1232,44 +1226,26 @@ class ApiController {
     log.debug CONTROLLER_NAME + "#${API_CALL}"
     ApiErrors errors = new ApiErrors()
     boolean reject = false;
+    String sessionToken = sanitizeSessionToken(params.sessionToken)
 
-    if (StringUtils.isEmpty(params.sessionToken)) {
-      log.debug("SessionToken is missing.")
-    }
-
-    String sessionToken = StringUtils.strip(params.sessionToken)
-
-    UserSession us = null;
+    UserSession us = getUserSession(sessionToken);
     Meeting meeting = null;
-    UserSession userSession = null;
 
-    if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null) {
+    if (us == null) {
       log.debug("No user with session token.")
       reject = true;
     } else {
-      us = meetingService.getUserSessionWithAuthToken(sessionToken);
       meeting = meetingService.getMeeting(us.meetingID);
       if (meeting == null || meeting.isForciblyEnded()) {
         log.debug("Meeting not found.")
         reject = true
       }
-      userSession = meetingService.getUserSessionWithAuthToken(sessionToken)
-      if (userSession == null) {
-        log.debug("Session with user not found.")
-        reject = true
-      }
-
     }
 
     // Determine the logout url so we can send the user there.
-    String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
-
-    if (us != null) {
-      logoutUrl = us.logoutUrl
-    }
+    String logoutUrl = us != null ? us.logoutUrl : paramsProcessorUtil.getDefaultLogoutUrl()
 
     if (reject) {
-      log.info("No session for user in conference.")
       response.addHeader("Cache-Control", "no-cache")
       withFormat {
         json {
@@ -1305,7 +1281,7 @@ class ApiController {
         clientURL = params.clientURL;
       }
 
-      String guestWaitStatus = userSession.guestStatus
+      String guestWaitStatus = us.guestStatus
 
       log.debug("GuestWaitStatus = " + guestWaitStatus)
 
@@ -1393,48 +1369,34 @@ class ApiController {
   def enter = {
     boolean reject = false;
 
-    if (StringUtils.isEmpty(params.sessionToken)) {
-      println("SessionToken is missing.")
-    }
-
-    String sessionToken = StringUtils.strip(params.sessionToken)
-
-    UserSession us = null;
+    String sessionToken = sanitizeSessionToken(params.sessionToken)
+    UserSession us = getUserSession(sessionToken);
     Meeting meeting = null;
-    UserSession userSession = null;
-
-    Boolean allowEnterWithoutSession = false;
-    // Depending on configuration, allow ENTER requests to proceed without session
-    if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
-      allowEnterWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
-    }
 
     String respMessage = "Session " + sessionToken + " not found."
 
-    if (!sessionToken || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowEnterWithoutSession && !session[sessionToken])) {
+    if (!hasValidSession(sessionToken)) {
       reject = true;
-      respMessage = "Session " + sessionToken + " not found."
     } else {
-      us = meetingService.getUserSessionWithAuthToken(sessionToken);
-      if (us == null) {
-        respMessage = "Session " + sessionToken + " not found."
+      meeting = meetingService.getMeeting(us.meetingID);
+      if (meeting == null || meeting.isForciblyEnded()) {
         reject = true
+        respMessage = "Meeting not found or ended for session " + sessionToken + "."
       } else {
-        meeting = meetingService.getMeeting(us.meetingID);
-        if (meeting == null || meeting.isForciblyEnded()) {
-          reject = true
-          respMessage = "Meeting not found or ended for session " + sessionToken + "."
-        }
-        if (us.guestStatus.equals(GuestPolicy.DENY)) {
-          respMessage = "User denied for user with session " + sessionToken + "."
-          reject = true
+        if (hasReachedMaxParticipants(meeting, us)) {
+          reject = true;
+          respMessage = "The number of participants allowed for this meeting has been reached.";
+        } else {
+          meeting.userEntered(us.internalUserId);
         }
       }
+      if (us.guestStatus.equals(GuestPolicy.DENY)) {
+        respMessage = "User denied for user with session " + sessionToken + "."
+        reject = true
+      }
     }
 
     if (reject) {
-      log.info("No session for user in conference.")
-
       // Determine the logout url so we can send the user there.
       String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
 
@@ -1549,25 +1511,13 @@ class ApiController {
   def stuns = {
     boolean reject = false;
 
-    UserSession us = null;
+    String sessionToken = sanitizeSessionToken(params.sessionToken)
+    UserSession us = getUserSession(sessionToken);
     Meeting meeting = null;
-    String sessionToken = null
-
-    if (!StringUtils.isEmpty(params.sessionToken)) {
-      sessionToken = StringUtils.strip(params.sessionToken)
-      println("Session token = [" + sessionToken + "]")
-    }
 
-    Boolean allowStunsWithoutSession = false;
-    // Depending on configuration, allow STUNS requests to proceed without session
-    if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
-      allowStunsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
-    }
-
-    if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowStunsWithoutSession && !session[sessionToken])) {
+    if (!hasValidSession(sessionToken)) {
       reject = true;
     } else {
-      us = meetingService.getUserSessionWithAuthToken(sessionToken);
       meeting = meetingService.getMeeting(us.meetingID);
       if (meeting == null || meeting.isForciblyEnded()) {
         reject = true
@@ -1575,8 +1525,6 @@ class ApiController {
     }
 
     if (reject) {
-      log.info("No session for user in conference.")
-
       String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
 
       response.addHeader("Cache-Control", "no-cache")
@@ -1633,12 +1581,7 @@ class ApiController {
    *************************************************/
   def signOut = {
 
-    String sessionToken = null
-
-    if (!StringUtils.isEmpty(params.sessionToken)) {
-      sessionToken = StringUtils.strip(params.sessionToken)
-      println("SessionToken = " + sessionToken)
-    }
+    String sessionToken = sanitizeSessionToken(params.sessionToken)
 
     Meeting meeting = null;
 
@@ -2155,6 +2098,76 @@ class ApiController {
     }
   }
 
+  def getUserSession(token) {
+    if (token == null) {
+      return null
+    }
+
+    UserSession us = meetingService.getUserSessionWithAuthToken(token)
+    if (us == null) {
+      log.info("Cannot find UserSession for token ${token}")
+    }
+
+    return us
+  }
+
+  def sanitizeSessionToken(param) {
+    if (param == null) {
+      log.info("sanitizeSessionToken: token is null")
+      return null
+    }
+
+    if (StringUtils.isEmpty(param)) {
+      log.info("sanitizeSessionToken: token is empty")
+      return null
+    }
+
+    return StringUtils.strip(param)
+  }
+
+  private Boolean hasValidSession(token) {
+    UserSession us = getUserSession(token)
+    if (us == null) {
+      return false
+    }
+
+    if (!session[token]) {
+      log.info("Session for token ${token} not found")
+
+      Boolean allowRequestsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession()
+      if (!allowRequestsWithoutSession) {
+        log.info("Meeting related to ${token} doesn't allow requests without session")
+        return false
+      }
+    }
+
+    log.info("Token ${token} is valid")
+    return true
+  }
+
+  // Validate maxParticipants constraint
+  private Boolean hasReachedMaxParticipants(meeting, us) {
+    // Meeting object calls it maxUsers to build up the drama
+    int maxParticipants = meeting.getMaxUsers();
+    // When is set to 0, the validation is ignored
+    Boolean enabled = maxParticipants > 0;
+    // Users refreshing page or reconnecting must be identified
+    Boolean rejoin = meeting.getUserById(us.internalUserId) != null;
+    // Users that passed enter once, still not joined but somehow re-entered
+    Boolean reenter = meeting.getEnteredUserById(us.internalUserId) != null;
+    // Users that already joined the meeting
+    int joinedUsers = meeting.getUsers().size()
+    // Users that are entering the meeting
+    int enteredUsers = meeting.getEnteredUsers().size()
+
+    Boolean reachedMax = (joinedUsers + enteredUsers) >= maxParticipants;
+    if (enabled && !rejoin && !reenter && reachedMax) {
+      return true;
+    }
+
+    return false;
+  }
+
   private void respondWithErrors(errorList, redirectResponse = false) {
     log.debug CONTROLLER_NAME + "#invalid"
     if (redirectResponse) {