You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by ex...@apache.org on 2021/05/01 03:31:09 UTC

[nifi] branch main updated: NIFI-7870 Resolved access to extension resources when using JWT

This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 1090a97  NIFI-7870 Resolved access to extension resources when using JWT
1090a97 is described below

commit 1090a9748a8ce5fe21d5a79d85a46858f1288f85
Author: Nathan Gough <th...@gmail.com>
AuthorDate: Mon Feb 22 11:28:01 2021 -0500

    NIFI-7870 Resolved access to extension resources when using JWT
    
    - Added SameSite Session Cookie __Host-Authorization-Bearer for sending JWT
    - Configured Spring Security CSRF Filter comparing Authorization header and Cookie JWT
    - Implemented BearerTokenResolver for retrieving JWT
    
    This closes #4988
    
    Signed-off-by: David Handermann <ex...@apache.org>
---
 .../replication/ThreadPoolRequestReplicator.java   |  36 +++--
 .../nifi-framework/nifi-web/nifi-web-api/pom.xml   |   6 +
 .../apache/nifi/web/NiFiCsrfTokenRepository.java   |  91 +++++++++++
 .../nifi/web/NiFiWebApiSecurityConfiguration.java  |  19 ++-
 .../org/apache/nifi/web/api/AccessResource.java    |  95 +++++------
 .../apache/nifi/web/security/LogoutException.java  |  35 ++++
 .../nifi/web/security/jwt/BearerTokenResolver.java |  30 ++++
 .../web/security/jwt/JwtAuthenticationFilter.java  |  41 +----
 .../apache/nifi/web/security/jwt/JwtService.java   |  13 +-
 ...ionFilter.java => NiFiBearerTokenResolver.java} |  57 +++----
 .../jwt/JwtAuthenticationFilterTest.groovy         | 180 ---------------------
 .../nifi/web/security/jwt/JwtServiceTest.java      |  32 ----
 .../security/jwt/NiFiBearerTokenResolverTest.java  | 124 ++++++++++++++
 .../webapp/js/nf/canvas/nf-canvas-error-handler.js |   2 +-
 .../src/main/webapp/js/nf/login/nf-login.js        |  13 +-
 .../nifi-web-ui/src/main/webapp/js/nf/nf-common.js |   8 +-
 16 files changed, 413 insertions(+), 369 deletions(-)

diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
index 49383fd..6665095 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
@@ -40,7 +40,7 @@ import org.apache.nifi.reporting.Severity;
 import org.apache.nifi.util.ComponentIdGenerator;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.security.ProxiedEntitiesUtils;
-import org.apache.nifi.web.security.jwt.JwtAuthenticationFilter;
+import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -244,25 +244,13 @@ public class ThreadPoolRequestReplicator implements RequestReplicator {
 
         // remove the access token if present, since the user is already authenticated... authorization
         // will happen when the request is replicated using the proxy chain above
-        headers.remove(JwtAuthenticationFilter.AUTHORIZATION);
+        headers.remove(NiFiBearerTokenResolver.AUTHORIZATION);
 
         // if knox sso cookie name is set, remove any authentication cookie since this user is already authenticated
         // and will be included in the proxied entities chain above... authorization will happen when the
         // request is replicated
-        final String knoxCookieName = nifiProperties.getKnoxCookieName();
-        if (headers.containsKey("Cookie") && StringUtils.isNotBlank(knoxCookieName)) {
-            final String rawCookies = headers.get("Cookie");
-            final String[] rawCookieParts = rawCookies.split(";");
-            final Set<String> filteredCookieParts = Stream.of(rawCookieParts).map(String::trim).filter(cookie -> !cookie.startsWith(knoxCookieName + "=")).collect(Collectors.toSet());
-
-            // if that was the only cookie, remove it
-            if (filteredCookieParts.isEmpty()) {
-                headers.remove("Cookie");
-            } else {
-                // otherwise rebuild the cookies without the knox token
-                headers.put("Cookie", StringUtils.join(filteredCookieParts, "; "));
-            }
-        }
+        removeCookie(headers, nifiProperties.getKnoxCookieName());
+        removeCookie(headers, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
 
         // remove the host header
         headers.remove("Host");
@@ -869,4 +857,20 @@ public class ThreadPoolRequestReplicator implements RequestReplicator {
         expiredRequestIds.forEach(id -> onResponseConsumed(id));
         return responseMap.size();
     }
+
+    private void removeCookie(Map<String, String> headers, final String cookieName) {
+        if (headers.containsKey("Cookie") && StringUtils.isNotBlank(cookieName)) {
+            final String rawCookies = headers.get("Cookie");
+            final String[] rawCookieParts = rawCookies.split(";");
+            final Set<String> filteredCookieParts = Stream.of(rawCookieParts).map(String::trim).filter(cookie -> !cookie.startsWith(cookieName + "=")).collect(Collectors.toSet());
+
+            // if that was the only cookie, remove it
+            if (filteredCookieParts.isEmpty()) {
+                headers.remove("Cookie");
+            } else {
+                // otherwise rebuild the cookies without the knox token
+                headers.put("Cookie", StringUtils.join(filteredCookieParts, "; "));
+            }
+        }
+    }
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml
index f2e01dc..865681e 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/pom.xml
@@ -436,5 +436,11 @@
             <groupId>org.slf4j</groupId>
             <artifactId>jcl-over-slf4j</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-http</artifactId>
+            <version>${jetty.version}</version>
+            <scope>compile</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiCsrfTokenRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiCsrfTokenRepository.java
new file mode 100644
index 0000000..1c4fc4c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiCsrfTokenRepository.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.nifi.web;
+
+
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.csrf.CsrfTokenRepository;
+import org.springframework.security.web.csrf.DefaultCsrfToken;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * A {@link CsrfTokenRepository} implementation for NiFi that matches the NiFi Cookie JWT against the
+ * Authorization header JWT to protect against CSRF. If the request is an idempotent method type, then only the Cookie
+ * is required to be present - this allows authenticating access to static resources using a Cookie. If the request is a non-idempotent
+ * method, NiFi requires the Authorization header (eg. for POST requests).
+ */
+public final class NiFiCsrfTokenRepository implements CsrfTokenRepository {
+
+    private static String EMPTY = "empty";
+    private CookieCsrfTokenRepository cookieRepository;
+
+    public NiFiCsrfTokenRepository() {
+        cookieRepository = new CookieCsrfTokenRepository();
+    }
+
+    @Override
+    public CsrfToken generateToken(HttpServletRequest request) {
+        // Return an empty value CsrfToken - it will not be saved to the response as our CSRF token is added elsewhere
+        return new DefaultCsrfToken(EMPTY, EMPTY, EMPTY);
+    }
+
+    @Override
+    public void saveToken(CsrfToken token, HttpServletRequest request,
+                          HttpServletResponse response) {
+        // Do nothing - we don't need to add new CSRF tokens to the response
+    }
+
+    @Override
+    public CsrfToken loadToken(HttpServletRequest request) {
+        CsrfToken cookie = cookieRepository.loadToken(request);
+        // We add the Bearer string here in order to match the Authorization header on comparison in CsrfFilter
+        return cookie != null ? new DefaultCsrfToken(cookie.getHeaderName(), cookie.getParameterName(), String.format("Bearer %s", cookie.getToken())) : null;
+    }
+
+    /**
+     * Sets the name of the HTTP request parameter that should be used to provide a token.
+     *
+     * @param parameterName the name of the HTTP request parameter that should be used to
+     * provide a token
+     */
+    public void setParameterName(String parameterName) {
+        cookieRepository.setParameterName(parameterName);
+    }
+
+    /**
+     * Sets the name of the HTTP header that should be used to provide the token.
+     *
+     * @param headerName the name of the HTTP header that should be used to provide the
+     * token
+     */
+    public void setHeaderName(String headerName) {
+        cookieRepository.setHeaderName(headerName);
+    }
+
+    /**
+     * Sets the name of the cookie that the expected CSRF token is saved to and read from.
+     *
+     * @param cookieName the name of the cookie that the expected CSRF token is saved to
+     * and read from
+     */
+    public void setCookieName(String cookieName) {
+        cookieRepository.setCookieName(cookieName);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
index 7403185..cf1e525 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
@@ -16,12 +16,12 @@
  */
 package org.apache.nifi.web;
 
-import java.util.Arrays;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationFilter;
 import org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationProvider;
 import org.apache.nifi.web.security.jwt.JwtAuthenticationFilter;
 import org.apache.nifi.web.security.jwt.JwtAuthenticationProvider;
+import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
 import org.apache.nifi.web.security.knox.KnoxAuthenticationFilter;
 import org.apache.nifi.web.security.knox.KnoxAuthenticationProvider;
 import org.apache.nifi.web.security.otp.OtpAuthenticationFilter;
@@ -46,10 +46,15 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
 import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
 import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
+import org.springframework.security.web.csrf.CsrfFilter;
+import org.springframework.security.web.util.matcher.AndRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
 import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.cors.CorsConfigurationSource;
 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 
+import java.util.Arrays;
+
 /**
  * NiFi Web Api Spring security. Applies the various NiFiAuthenticationFilter servlet filters which will extract authentication
  * credentials from API requests.
@@ -116,14 +121,16 @@ public class NiFiWebApiSecurityConfiguration extends WebSecurityConfigurerAdapte
 
     @Override
     protected void configure(HttpSecurity http) throws Exception {
+        NiFiCsrfTokenRepository csrfRepository = new NiFiCsrfTokenRepository();
+        csrfRepository.setHeaderName(NiFiBearerTokenResolver.AUTHORIZATION);
+        csrfRepository.setCookieName(NiFiBearerTokenResolver.JWT_COOKIE_NAME);
+
         http
                 .cors().and()
                 .rememberMe().disable()
-                .authorizeRequests()
-                    .anyRequest().fullyAuthenticated()
-                    .and()
-                .sessionManagement()
-                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
+                .authorizeRequests().anyRequest().fullyAuthenticated().and()
+                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
+                .csrf().requireCsrfProtectionMatcher(new AndRequestMatcher(CsrfFilter.DEFAULT_CSRF_MATCHER, new RequestHeaderRequestMatcher("Cookie"))).csrfTokenRepository(csrfRepository);
 
         // x509
         http.addFilterBefore(x509FilterBean(), AnonymousAuthenticationFilter.class);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
index bf4a792..0221485 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
@@ -60,12 +60,13 @@ import org.apache.nifi.web.api.dto.AccessStatusDTO;
 import org.apache.nifi.web.api.entity.AccessConfigurationEntity;
 import org.apache.nifi.web.api.entity.AccessStatusEntity;
 import org.apache.nifi.web.security.InvalidAuthenticationException;
+import org.apache.nifi.web.security.LogoutException;
 import org.apache.nifi.web.security.ProxiedEntitiesUtils;
 import org.apache.nifi.web.security.UntrustedProxyException;
-import org.apache.nifi.web.security.jwt.JwtAuthenticationFilter;
 import org.apache.nifi.web.security.jwt.JwtAuthenticationProvider;
 import org.apache.nifi.web.security.jwt.JwtAuthenticationRequestToken;
 import org.apache.nifi.web.security.jwt.JwtService;
+import org.apache.nifi.web.security.jwt.NiFiBearerTokenResolver;
 import org.apache.nifi.web.security.kerberos.KerberosService;
 import org.apache.nifi.web.security.knox.KnoxService;
 import org.apache.nifi.web.security.logout.LogoutRequest;
@@ -82,6 +83,8 @@ import org.apache.nifi.web.security.token.OtpAuthenticationToken;
 import org.apache.nifi.web.security.x509.X509AuthenticationProvider;
 import org.apache.nifi.web.security.x509.X509AuthenticationRequestToken;
 import org.apache.nifi.web.security.x509.X509CertificateExtractor;
+import org.eclipse.jetty.http.HttpCookie;
+import org.eclipse.jetty.http.HttpHeader;
 import org.opensaml.saml2.metadata.provider.MetadataProviderException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -90,6 +93,7 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.saml.SAMLCredential;
 import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
+import org.springframework.web.util.WebUtils;
 
 import javax.servlet.ServletContext;
 import javax.servlet.http.Cookie;
@@ -106,6 +110,7 @@ import javax.ws.rs.core.Context;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.ResponseBuilder;
 import javax.ws.rs.core.UriBuilder;
 import javax.ws.rs.core.UriInfo;
 import java.io.IOException;
@@ -143,6 +148,7 @@ public class AccessResource extends ApplicationResource {
     private static final Pattern REVOKE_ACCESS_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.google\\.com)");
     private static final Pattern ID_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.okta)");
     private static final int msTimeout = 30_000;
+    private static final int VALID_FOR_SESSION_ONLY = -1;
 
     private static final String SAML_REQUEST_IDENTIFIER = "saml-request-identifier";
     private static final String SAML_METADATA_MEDIA_TYPE = "application/samlmetadata+xml";
@@ -342,7 +348,7 @@ public class AccessResource extends ApplicationResource {
         initializeSamlServiceProvider();
 
         // ensure the request has the cookie with the request id
-        final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
+        final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue();
         if (samlRequestIdentifier == null) {
             forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue.");
             return;
@@ -435,7 +441,7 @@ public class AccessResource extends ApplicationResource {
         initializeSamlServiceProvider();
 
         // ensure the request has the cookie with the request identifier
-        final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
+        final String samlRequestIdentifier = WebUtils.getCookie(httpServletRequest, SAML_REQUEST_IDENTIFIER).getValue();
         if (samlRequestIdentifier == null) {
             final String message = "The login request identifier was not found in the request. Unable to continue.";
             logger.warn(message);
@@ -479,7 +485,7 @@ public class AccessResource extends ApplicationResource {
         }
 
         // ensure the logout request identifier is present
-        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
         if (StringUtils.isBlank(logoutRequestIdentifier)) {
             forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
             return;
@@ -585,7 +591,7 @@ public class AccessResource extends ApplicationResource {
         initializeSamlServiceProvider();
 
         // ensure the logout request identifier is present
-        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
         if (StringUtils.isBlank(logoutRequestIdentifier)) {
             forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
             return;
@@ -732,7 +738,7 @@ public class AccessResource extends ApplicationResource {
             return;
         }
 
-        final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
+        final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
         if (oidcRequestIdentifier == null) {
             forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
                     "not found in the request. Unable to continue.");
@@ -785,7 +791,6 @@ public class AccessResource extends ApplicationResource {
 
                 // store the NiFi token
                 oidcService.storeJwt(oidcRequestIdentifier, nifiJwt);
-
             } catch (final Exception e) {
                 logger.error(OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage(), e);
 
@@ -831,7 +836,7 @@ public class AccessResource extends ApplicationResource {
             return Response.status(Response.Status.CONFLICT).entity(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG).build();
         }
 
-        final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
+        final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
         if (oidcRequestIdentifier == null) {
             final String message = "The login request identifier was not found in the request. Unable to continue.";
             logger.warn(message);
@@ -847,8 +852,7 @@ public class AccessResource extends ApplicationResource {
             throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue.");
         }
 
-        // generate the response
-        return generateOkResponse(jwt).build();
+        return generateTokenResponse(generateOkResponse(jwt), jwt);
     }
 
     @GET
@@ -868,6 +872,10 @@ public class AccessResource extends ApplicationResource {
             throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
         }
 
+        final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity();
+        removeCookie(httpServletResponse, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
+        logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity);
+
         // Get the oidc discovery url
         String oidcDiscoveryUrl = properties.getOidcDiscoveryUrl();
 
@@ -920,7 +928,7 @@ public class AccessResource extends ApplicationResource {
             return;
         }
 
-        final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
+        final String oidcRequestIdentifier = WebUtils.getCookie(httpServletRequest, OIDC_REQUEST_IDENTIFIER).getValue();
         if (oidcRequestIdentifier == null) {
             forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
                     "not found in the request. Unable to continue.");
@@ -1164,8 +1172,8 @@ public class AccessResource extends ApplicationResource {
 
             // if there is not certificate, consider a token
             if (certificates == null) {
-                // look for an authorization token
-                final String authorization = httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION);
+                // look for an authorization token in header or cookie
+                final String authorization = new NiFiBearerTokenResolver().resolve(httpServletRequest);
 
                 // if there is no authorization header, we don't know the user
                 if (authorization == null) {
@@ -1173,10 +1181,8 @@ public class AccessResource extends ApplicationResource {
                     accessStatus.setMessage("No credentials supplied, unknown user.");
                 } else {
                     try {
-                        // Extract the Base64 encoded token from the Authorization header
-                        final String token = StringUtils.substringAfterLast(authorization, " ");
-
-                        final JwtAuthenticationRequestToken jwtRequest = new JwtAuthenticationRequestToken(token, httpServletRequest.getRemoteAddr());
+                        // authenticate the token
+                        final JwtAuthenticationRequestToken jwtRequest = new JwtAuthenticationRequestToken(authorization, httpServletRequest.getRemoteAddr());
                         final NiFiAuthenticationToken authenticationResponse = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(jwtRequest);
                         final NiFiUser nifiUser = ((NiFiUserDetails) authenticationResponse.getDetails()).getNiFiUser();
 
@@ -1328,7 +1334,7 @@ public class AccessResource extends ApplicationResource {
             value = "Creates a token for accessing the REST API via Kerberos ticket exchange / SPNEGO negotiation",
             notes = "The token returned is formatted as a JSON Web Token (JWT). The token is base64 encoded and comprised of three parts. The header, " +
                     "the body, and the signature. The expiration of the token is a contained within the body. The token can be used in the Authorization header " +
-                    "in the format 'Authorization: Bearer <token>'.",
+                    "in the format 'Authorization: Bearer <token>'. It is also stored in the browser as a cookie.",
             response = String.class
     )
     @ApiResponses(
@@ -1383,7 +1389,7 @@ public class AccessResource extends ApplicationResource {
 
                 // build the response
                 final URI uri = URI.create(generateResourceUri("access", "kerberos"));
-                return generateCreatedResponse(uri, token).build();
+                return generateTokenResponse(generateCreatedResponse(uri, token), token);
             } catch (final AuthenticationException e) {
                 throw new AccessDeniedException(e.getMessage(), e);
             }
@@ -1391,12 +1397,12 @@ public class AccessResource extends ApplicationResource {
     }
 
     /**
-     * Creates a token for accessing the REST API via username/password.
+     * Creates a token for accessing the REST API via username/password stored as a cookie in the browser.
      *
      * @param httpServletRequest the servlet request
      * @param username           the username
      * @param password           the password
-     * @return A JWT (string)
+     * @return A JWT (string) in a cookie and as the body
      */
     @POST
     @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@@ -1405,8 +1411,8 @@ public class AccessResource extends ApplicationResource {
     @ApiOperation(
             value = "Creates a token for accessing the REST API via username/password",
             notes = "The token returned is formatted as a JSON Web Token (JWT). The token is base64 encoded and comprised of three parts. The header, " +
-                    "the body, and the signature. The expiration of the token is a contained within the body. The token can be used in the Authorization header " +
-                    "in the format 'Authorization: Bearer <token>'.",
+                    "the body, and the signature. The expiration of the token is a contained within the body. It is stored in the browser as a cookie, but also returned in" +
+                    "the response body to be stored/used by third party client scripts.",
             response = String.class
     )
     @ApiResponses(
@@ -1459,7 +1465,7 @@ public class AccessResource extends ApplicationResource {
 
         // build the response
         final URI uri = URI.create(generateResourceUri("access", "token"));
-        return generateCreatedResponse(uri, token).build();
+        return generateTokenResponse(generateCreatedResponse(uri, token), token);
     }
 
     @DELETE
@@ -1490,8 +1496,9 @@ public class AccessResource extends ApplicationResource {
 
         try {
             logger.info("Logging out " + mappedUserIdentity);
-            jwtService.logOutUsingAuthHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
-            logger.info("Successfully invalidated JWT for " + mappedUserIdentity);
+            logOutUser(httpServletRequest);
+            removeCookie(httpServletResponse, NiFiBearerTokenResolver.JWT_COOKIE_NAME);
+            logger.debug("Invalidated JWT for user [{}]", mappedUserIdentity);
 
             // create a LogoutRequest and tell the LogoutRequestManager about it for later retrieval
             final LogoutRequest logoutRequest = new LogoutRequest(UUID.randomUUID().toString(), mappedUserIdentity);
@@ -1507,7 +1514,10 @@ public class AccessResource extends ApplicationResource {
 
             return generateOkResponse().build();
         } catch (final JwtException e) {
-            logger.error("Logout of user " + mappedUserIdentity + " failed due to: " + e.getMessage(), e);
+            logger.error("JWT processing failed for [{}], due to: ", mappedUserIdentity, e.getMessage(), e);
+            return Response.serverError().build();
+        } catch (final LogoutException e) {
+            logger.error("Logout failed for user [{}] due to: ", mappedUserIdentity, e.getMessage(), e);
             return Response.serverError().build();
         }
     }
@@ -1543,7 +1553,7 @@ public class AccessResource extends ApplicationResource {
         LogoutRequest logoutRequest = null;
 
         // check if a logout request identifier is present and if so complete the request
-        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        final String logoutRequestIdentifier = WebUtils.getCookie(httpServletRequest, LOGOUT_REQUEST_IDENTIFIER).getValue();
         if (logoutRequestIdentifier != null) {
             logoutRequest = logoutRequestManager.complete(logoutRequestIdentifier);
         }
@@ -1577,25 +1587,6 @@ public class AccessResource extends ApplicationResource {
         return proposedTokenExpiration;
     }
 
-    /**
-     * Gets the value of a cookie matching the specified name. If no cookie with that name exists, null is returned.
-     *
-     * @param cookies the cookies
-     * @param name    the name of the cookie
-     * @return the value of the corresponding cookie, or null if the cookie does not exist
-     */
-    private String getCookieValue(final Cookie[] cookies, final String name) {
-        if (cookies != null) {
-            for (final Cookie cookie : cookies) {
-                if (name.equals(cookie.getName())) {
-                    return cookie.getValue();
-                }
-            }
-        }
-
-        return null;
-    }
-
     private String getOidcCallback() {
         return generateResourceUri("access", "oidc", "callback");
     }
@@ -1812,4 +1803,14 @@ public class AccessResource extends ApplicationResource {
         this.logoutRequestManager = logoutRequestManager;
     }
 
+    private void logOutUser(HttpServletRequest httpServletRequest) {
+        final String jwt = new NiFiBearerTokenResolver().resolve(httpServletRequest);
+        jwtService.logOut(jwt);
+    }
+
+    private Response generateTokenResponse(ResponseBuilder builder, String token) {
+        // currently there is no way to use javax.servlet-api to set SameSite=Strict, so we do this using Jetty
+        HttpCookie jwtCookie = new HttpCookie(NiFiBearerTokenResolver.JWT_COOKIE_NAME, token, null, "/", VALID_FOR_SESSION_ONLY, true, true, null, 0, HttpCookie.SameSite.STRICT);
+        return builder.header(HttpHeader.SET_COOKIE.asString(), jwtCookie.getRFC6265SetCookie()).build();
+    }
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/LogoutException.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/LogoutException.java
new file mode 100644
index 0000000..4af1191
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/LogoutException.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.nifi.web.security;
+
+import org.springframework.security.core.AuthenticationException;
+
+/**
+ * Thrown if the authentication of a given request is invalid. For instance,
+ * an expired certificate or token.
+ */
+public class LogoutException extends AuthenticationException {
+
+    public LogoutException(String msg) {
+        super(msg);
+    }
+
+    public LogoutException(String msg, Throwable t) {
+        super(msg, t);
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/BearerTokenResolver.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/BearerTokenResolver.java
new file mode 100644
index 0000000..caf8789
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/BearerTokenResolver.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.nifi.web.security.jwt;
+
+import javax.servlet.http.HttpServletRequest;
+
+public interface BearerTokenResolver {
+    /**
+     * Resolve any
+     * <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer
+     * Token</a> value from the request.
+     * @param request the request
+     * @return the Bearer Token value or {@code null} if none found
+     */
+    String resolve(HttpServletRequest request);
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
index 9a25ff3..6ac2401 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
@@ -16,60 +16,35 @@
  */
 package org.apache.nifi.web.security.jwt;
 
-import org.apache.nifi.web.security.InvalidAuthenticationException;
+import org.apache.nifi.util.StringUtils;
 import org.apache.nifi.web.security.NiFiAuthenticationFilter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.security.core.Authentication;
 
 import javax.servlet.http.HttpServletRequest;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
-/**
- */
 public class JwtAuthenticationFilter extends NiFiAuthenticationFilter {
 
     private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
 
     // The Authorization header contains authentication credentials
-    public static final String AUTHORIZATION = "Authorization";
-    private static final Pattern tokenPattern = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*)$");
+    private static NiFiBearerTokenResolver bearerTokenResolver = new NiFiBearerTokenResolver();
 
     @Override
     public Authentication attemptAuthentication(final HttpServletRequest request) {
-        // only support jwt login when running securely
+        // Only support JWT login when running securely
         if (!request.isSecure()) {
             return null;
         }
 
-        // TODO: Refactor request header extraction logic to shared utility as it is duplicated in AccessResource
-
-        // get the principal out of the user token
-        final String authorizationHeader = request.getHeader(AUTHORIZATION);
+        // Get JWT from Authorization header or cookie value
+        final String headerToken = bearerTokenResolver.resolve(request);
 
-        // if there is no authorization header, we don't know the user
-        if (authorizationHeader == null || !validJwtFormat(authorizationHeader)) {
-            return null;
+        if (StringUtils.isNotBlank(headerToken)) {
+            return new JwtAuthenticationRequestToken(headerToken, request.getRemoteAddr());
         } else {
-            // Extract the Base64 encoded token from the Authorization header
-            final String token = getTokenFromHeader(authorizationHeader);
-            return new JwtAuthenticationRequestToken(token, request.getRemoteAddr());
-        }
-    }
-
-    private boolean validJwtFormat(String authenticationHeader) {
-        Matcher matcher = tokenPattern.matcher(authenticationHeader);
-        return matcher.matches();
-    }
-
-    public static String getTokenFromHeader(String authenticationHeader) {
-        Matcher matcher = tokenPattern.matcher(authenticationHeader);
-        if(matcher.matches()) {
-            return matcher.group(1);
-        } else {
-            throw new InvalidAuthenticationException("JWT did not match expected pattern.");
+            return null;
         }
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
index 886ae46..d235d49 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
@@ -31,6 +31,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.admin.service.AdministrationException;
 import org.apache.nifi.admin.service.KeyService;
 import org.apache.nifi.key.Key;
+import org.apache.nifi.web.security.LogoutException;
 import org.apache.nifi.web.security.token.LoginAuthenticationToken;
 import org.slf4j.LoggerFactory;
 
@@ -186,7 +187,7 @@ public class JwtService {
      * @throws JwtException if there is a problem with the token input
      * @throws Exception if there is an issue logging the user out
      */
-    public void logOut(String token) {
+    public void logOut(String token) throws LogoutException {
         Jws<Claims> claims = parseTokenFromBase64EncodedString(token);
 
         // Get the key ID from the claims
@@ -199,13 +200,9 @@ public class JwtService {
         try {
             keyService.deleteKey(keyId);
         } catch (Exception e) {
-            logger.error("The key with key ID: " + keyId + " failed to be removed from the user database.");
-            throw e;
+            final String errorMessage = String.format("The key with key ID: %s failed to be removed from the user database.", keyId);
+            logger.error(errorMessage);
+            throw new LogoutException(errorMessage);
         }
     }
-
-    public void logOutUsingAuthHeader(String authorizationHeader) {
-        String base64EncodedToken = JwtAuthenticationFilter.getTokenFromHeader(authorizationHeader);
-        logOut(base64EncodedToken);
-    }
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolver.java
similarity index 50%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolver.java
index 9a25ff3..c936c4d 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolver.java
@@ -16,60 +16,55 @@
  */
 package org.apache.nifi.web.security.jwt;
 
+import org.apache.nifi.util.StringUtils;
 import org.apache.nifi.web.security.InvalidAuthenticationException;
-import org.apache.nifi.web.security.NiFiAuthenticationFilter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.security.core.Authentication;
+import org.springframework.web.util.WebUtils;
 
+import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-/**
- */
-public class JwtAuthenticationFilter extends NiFiAuthenticationFilter {
-
-    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
-
-    // The Authorization header contains authentication credentials
+public class NiFiBearerTokenResolver implements BearerTokenResolver {
+    private static final Logger logger = LoggerFactory.getLogger(NiFiBearerTokenResolver.class);
+    private static final Pattern BEARER_HEADER_PATTERN = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*){1}$");
+    private static final Pattern JWT_PATTERN = Pattern.compile("^(\\S*\\.\\S*\\.\\S*)$");
     public static final String AUTHORIZATION = "Authorization";
-    private static final Pattern tokenPattern = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*)$");
+    public static final String JWT_COOKIE_NAME = "__Host-Authorization-Bearer";
 
     @Override
-    public Authentication attemptAuthentication(final HttpServletRequest request) {
-        // only support jwt login when running securely
-        if (!request.isSecure()) {
-            return null;
-        }
-
-        // TODO: Refactor request header extraction logic to shared utility as it is duplicated in AccessResource
-
-        // get the principal out of the user token
+    public String resolve(HttpServletRequest request) {
         final String authorizationHeader = request.getHeader(AUTHORIZATION);
+        final Cookie cookieHeader = WebUtils.getCookie(request, JWT_COOKIE_NAME);
 
-        // if there is no authorization header, we don't know the user
-        if (authorizationHeader == null || !validJwtFormat(authorizationHeader)) {
-            return null;
+        if (StringUtils.isNotBlank(authorizationHeader) && validAuthorizationHeaderFormat(authorizationHeader)) {
+            return getTokenFromHeader(authorizationHeader);
+        } else if(cookieHeader != null && validJwtFormat(cookieHeader.getValue())) {
+            return cookieHeader.getValue();
         } else {
-            // Extract the Base64 encoded token from the Authorization header
-            final String token = getTokenFromHeader(authorizationHeader);
-            return new JwtAuthenticationRequestToken(token, request.getRemoteAddr());
+            logger.debug("Authorization header was not present or not in a valid format.");
+            return null;
         }
     }
 
-    private boolean validJwtFormat(String authenticationHeader) {
-        Matcher matcher = tokenPattern.matcher(authenticationHeader);
+    private boolean validAuthorizationHeaderFormat(String authorizationHeader) {
+        Matcher matcher = BEARER_HEADER_PATTERN.matcher(authorizationHeader);
+        return matcher.matches();
+    }
+
+    private boolean validJwtFormat(String jwt) {
+        Matcher matcher = JWT_PATTERN.matcher(jwt);
         return matcher.matches();
     }
 
-    public static String getTokenFromHeader(String authenticationHeader) {
-        Matcher matcher = tokenPattern.matcher(authenticationHeader);
-        if(matcher.matches()) {
+    private String getTokenFromHeader(String authenticationHeader) {
+        Matcher matcher = BEARER_HEADER_PATTERN.matcher(authenticationHeader);
+        if (matcher.matches()) {
             return matcher.group(1);
         } else {
             throw new InvalidAuthenticationException("JWT did not match expected pattern.");
         }
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/jwt/JwtAuthenticationFilterTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/jwt/JwtAuthenticationFilterTest.groovy
deleted file mode 100644
index dcabb0f..0000000
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/jwt/JwtAuthenticationFilterTest.groovy
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.nifi.web.security.jwt
-
-import groovy.json.JsonOutput
-import org.apache.nifi.web.security.InvalidAuthenticationException
-import org.junit.After
-import org.junit.AfterClass
-import org.junit.Before
-import org.junit.BeforeClass
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.ExpectedException
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4.class)
-class JwtAuthenticationFilterTest extends GroovyTestCase {
-
-    public static String jwtString
-
-    @Rule
-    public ExpectedException expectedException = ExpectedException.none()
-
-    @BeforeClass
-    static void setUpOnce() throws Exception {
-        final String ALG_HEADER = "{\"alg\":\"HS256\"}"
-        final int EXPIRATION_SECONDS = 500
-        Calendar now = Calendar.getInstance()
-        final long currentTime = (long) (now.getTimeInMillis() / 1000.0)
-        final long TOKEN_ISSUED_AT = currentTime
-        final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS
-
-        // Generate a token that we will add a valid signature from a different token
-        // Always use LinkedHashMap to enforce order of the keys because the signature depends on order
-        final String EXPECTED_PAYLOAD =
-                JsonOutput.toJson(
-                    sub:'unknownuser',
-                    iss:'MockIdentityProvider',
-                    aud:'MockIdentityProvider',
-                    preferred_username:'unknownuser',
-                    kid:1,
-                    exp:TOKEN_EXPIRATION_SECONDS,
-                    iat:TOKEN_ISSUED_AT)
-
-        // Set up our JWT string with a test token
-        jwtString = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true)
-
-    }
-
-    @AfterClass
-    static void tearDownOnce() throws Exception {
-
-    }
-
-    @Before
-    void setUp() throws Exception {
-
-    }
-
-    @After
-    void tearDown() throws Exception {
-
-    }
-
-    @Test
-    void testValidAuthenticationHeaderString() {
-        // Arrange
-        String authenticationHeader = "Bearer " + jwtString
-
-        // Act
-        boolean isValidHeader = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
-
-        // Assert
-        assertTrue(isValidHeader)
-    }
-
-    @Test
-    void testMissingBearer() {
-        // Arrange
-
-        // Act
-        boolean isValidHeader = new JwtAuthenticationFilter().validJwtFormat(jwtString)
-
-        // Assert
-        assertFalse(isValidHeader)
-    }
-
-    @Test
-    void testExtraCharactersAtBeginningOfToken() {
-        // Arrange
-        String authenticationHeader = "xBearer " + jwtString
-
-        // Act
-        boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
-
-        // Assert
-        assertFalse(isValidToken)
-    }
-
-    @Test
-    void testBadTokenFormat() {
-        // Arrange
-        String[] tokenStrings = jwtString.split("\\.")
-        String badToken = "Bearer " + tokenStrings[1] + tokenStrings[2]
-
-        // Act
-        boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(badToken)
-
-        // Assert
-        assertFalse(isValidToken)
-    }
-
-    @Test
-    void testMultipleTokenInvalid() {
-        // Arrange
-        String authenticationHeader = "Bearer " + jwtString
-        authenticationHeader = authenticationHeader + " " + authenticationHeader
-
-        // Act
-        boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
-
-        // Assert
-        assertFalse(isValidToken)
-    }
-
-    @Test
-    void testExtractToken() {
-        // Arrange
-        String authenticationHeader = "Bearer " + jwtString
-
-        // Act
-        String extractedToken = new JwtAuthenticationFilter().getTokenFromHeader(authenticationHeader)
-
-        // Assert
-        assertEquals(jwtString, extractedToken)
-    }
-
-    @Test
-    void testMultipleTokenDottedInvalid() {
-        // Arrange
-        String authenticationHeader = "Bearer " + jwtString
-        authenticationHeader = authenticationHeader + "." + authenticationHeader
-
-        // Act
-        boolean isValidToken = new JwtAuthenticationFilter().validJwtFormat(authenticationHeader)
-
-        // Assert
-        assertFalse(isValidToken)
-    }
-
-    @Test
-    void testMultipleTokenNotExtracted() {
-        // Arrange
-        expectedException.expect(InvalidAuthenticationException.class)
-        expectedException.expectMessage("JWT did not match expected pattern.")
-        String authenticationHeader = "Bearer " + jwtString
-        authenticationHeader = authenticationHeader + " " + authenticationHeader
-
-        // Act
-        String token = new JwtAuthenticationFilter().getTokenFromHeader(authenticationHeader)
-
-        // Assert
-        // Expect InvalidAuthenticationException
-    }
-}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
index eb1f5ee..513eb0c 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
@@ -599,38 +599,6 @@ public class JwtServiceTest {
     }
 
     @Test
-    public void testShouldLogOutUserUsingAuthHeader() throws Exception {
-        // Arrange
-        expectedException.expect(JwtException.class);
-        expectedException.expectMessage("Unable to validate the access token.");
-
-        // Token expires in 60 seconds
-        final int EXPIRATION_MILLIS = 60000;
-        LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken(DEFAULT_IDENTITY,
-                EXPIRATION_MILLIS,
-                "MockIdentityProvider");
-        logger.info("Generating token for " + loginAuthenticationToken);
-
-        // Act
-        String token = jwtService.generateSignedToken(loginAuthenticationToken);
-
-        logger.info("Generated JWT: " + token);
-        logger.info("Validating token...");
-        String authID = jwtService.getAuthenticationFromToken(token);
-        assertEquals(DEFAULT_IDENTITY, authID);
-        logger.info("Token was valid");
-        logger.info("Logging out user: " + authID);
-        String header = "Bearer " + token;
-        jwtService.logOutUsingAuthHeader(header);
-        logger.info("Logged out user: " + authID);
-        logger.info("Checking that token is now invalid...");
-        jwtService.getAuthenticationFromToken(token);
-
-        // Assert
-        // Should throw exception when user is not found
-    }
-
-    @Test
     public void testLogoutWhenAuthTokenIsEmptyShouldThrowError() throws Exception {
         // Arrange
         expectedException.expect(JwtException.class);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolverTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolverTest.java
new file mode 100644
index 0000000..e14c7ed
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/NiFiBearerTokenResolverTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.nifi.web.security.jwt;
+
+import groovy.json.JsonOutput;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class NiFiBearerTokenResolverTest {
+
+    public static String jwtString;
+
+    @Mock
+    private static HttpServletRequest request;
+
+    @BeforeClass
+    public static void setUpOnce() throws Exception {
+        final String ALG_HEADER = "{\"alg\":\"HS256\"}";
+        final int EXPIRATION_SECONDS = 500;
+        Calendar now = Calendar.getInstance();
+        final long currentTime = (long) (now.getTimeInMillis() / 1000.0);
+        final long TOKEN_ISSUED_AT = currentTime;
+        final long TOKEN_EXPIRATION_SECONDS = currentTime + EXPIRATION_SECONDS;
+
+        Map<String, String> hashMap = new HashMap<String, String>() {{
+            put("sub", "unknownuser");
+            put("iss", "MockIdentityProvider");
+            put("aud", "MockIdentityProvider");
+            put("preferred_username", "unknownuser");
+            put("kid", String.valueOf(1));
+            put("exp", String.valueOf(TOKEN_EXPIRATION_SECONDS));
+            put("iat", String.valueOf(TOKEN_ISSUED_AT));
+        }};
+
+        // Generate a token that we will add a valid signature from a different token
+        // Always use LinkedHashMap to enforce order of the keys because the signature depends on order
+        final String EXPECTED_PAYLOAD = JsonOutput.toJson(hashMap);
+
+        // Set up our JWT string with a test token
+        jwtString = JwtServiceTest.generateHS256Token(ALG_HEADER, EXPECTED_PAYLOAD, true, true);
+
+        request = mock(HttpServletRequest.class);
+    }
+
+    @Test
+    public void testValidAuthenticationHeaderString() {
+        String authenticationHeader = "Bearer " + jwtString;
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
+        String isValidHeader = new NiFiBearerTokenResolver().resolve(request);
+
+        assertEquals(jwtString, isValidHeader);
+    }
+
+    @Test
+    public void testMissingBearer() {
+        String authenticationHeader = jwtString;
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
+        String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
+
+        assertNull(resolvedToken);
+    }
+
+    @Test
+    public void testExtraCharactersAtBeginningOfToken() {
+        String authenticationHeader = "xBearer " + jwtString;
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
+        String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
+
+        assertNull(resolvedToken);
+    }
+
+    @Test
+    public void testBadTokenFormat() {
+        String[] tokenStrings = jwtString.split("\\.");
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(String.valueOf("Bearer " + tokenStrings[1] + tokenStrings[2]));
+        String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
+
+        assertNull(resolvedToken);
+    }
+
+    @Test
+    public void testMultipleTokenInvalid() {
+        String authenticationHeader = "Bearer " + jwtString;
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(String.format("%s %s", authenticationHeader, authenticationHeader));
+        String resolvedToken = new NiFiBearerTokenResolver().resolve(request);
+
+        assertNull(resolvedToken);
+    }
+
+    @Test
+    public void testExtractToken() {
+        String authenticationHeader = "Bearer " + jwtString;
+        when(request.getHeader(eq(NiFiBearerTokenResolver.AUTHORIZATION))).thenReturn(authenticationHeader);
+        String extractedToken = new NiFiBearerTokenResolver().resolve(request);
+
+        assertEquals(jwtString, extractedToken);
+    }
+}
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas-error-handler.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas-error-handler.js
index ad4305f..17464c8 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas-error-handler.js
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas-error-handler.js
@@ -45,7 +45,7 @@
         // In case no further requests will be successful based on the status,
         // the canvas is disabled, and the message pane is shown.
         if ($('#message-pane').is(':visible')) {
-            nfCommon.showLogoutLink();
+            nfCommon.updateLogoutLink();
 
             // hide the splash screen if required
             if ($('#splash').is(':visible')) {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js
index 3c1b95f..72cd6c2 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/login/nf-login.js
@@ -99,7 +99,7 @@
                 'password': $('#password').val()
             }
         }).done(function (jwt) {
-            // get the payload and store the token with the appropirate expiration
+            // Get the payload and store the token with the appropriate expiration. JWT is also stored automatically in a cookie.
             var token = nfCommon.getJwtPayload(jwt);
             var expiration = parseInt(token['exp'], 10) * nfCommon.MILLIS_PER_SECOND;
             nfStorage.setItem('jwt', jwt, expiration);
@@ -112,9 +112,6 @@
             }).done(function (response) {
                 var accessStatus = response.accessStatus;
 
-                // update the logout link appropriately
-                showLogoutLink();
-
                 // update according to the access status
                 if (accessStatus.status === 'ACTIVE') {
                     // reload as appropriate - no need to schedule token refresh as the page is reloading
@@ -155,10 +152,6 @@
         });
     };
 
-    var showLogoutLink = function () {
-        nfCommon.showLogoutLink();
-    };
-
     var nfLogin = {
         /**
          * Initializes the login page.
@@ -166,9 +159,7 @@
         init: function () {
             nfStorage.init();
 
-            if (nfStorage.getItem('jwt') !== null) {
-                showLogoutLink();
-            }
+            nfCommon.updateLogoutLink();
 
             // supporting logging in via enter press
             $('#username, #password').on('keyup', function (e) {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
index be6b370..2b79896 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
@@ -852,11 +852,11 @@
         /**
          * Shows the logout link if appropriate.
          */
-        showLogoutLink: function () {
-            if (nfStorage.getItem('jwt') === null) {
-                $('#user-logout-container').css('display', 'none');
-            } else {
+        updateLogoutLink: function () {
+            if (nfStorage.getItem('jwt') !== null) {
                 $('#user-logout-container').css('display', 'block');
+            } else {
+                $('#user-logout-container').css('display', 'none');
             }
         },