The nVisium Blog

Play 2.6 Security Analysis

Play 2.6 final was recently released and it includes a ton of awesome new features. Some of the most exciting features include: replacing Netty with Akka HTTP Server as the default backend as well as shipping with experimental HTTP/2 support (finally!). From a security perspective, Play 2.6 introduces new features and settings you want to take advantage of.

Migrating to Play 2.6

At nVisium, we are big fans of Play Framework. We especially love Scala, and we've recently migrated some of our services to Play 2.6. As a security company that builds software products as well as performs consulting services to help others secure their products, we like to look under the hood of the technologies we use. This post covers some of the more important and useful security updates that we found in the Play 2.6 release.

We spent time reading framework documentation as well as reviewing commits and functionality added since 2.5 in order to better understand the internals of these security enhancements. Some of these updates are available in the official framework documentation, but some of the other items required digging through GitHub commits at the official repository and diffs between the 2.5x and 2.6x branches. This post should hopefully bridge the gap between the documentation and some of the finer points of the framework updates.

Security Filters

Play 2.6 introduces several new security filters that aim to make applications more inherently secure out of the box. They will also make your application unusable and break things if you don't refactor your application to handle them. I'd encourage you to consider refactoring rather than just turning them off as they provide a lot of quick security wins. Newly supported filters for 2.6 include the Allowed Hosts filter, CSRF filter, and Security Headers filter. The Allowed Hosts, CSRF, and Security Headers filters are all enabled by default.

Cross Site Request Forgery (CSRF)

CSRF is an attack that allows a victim's browser to be forced into executing an authenticated transaction unintentionally. The CSRF filter is used to enable checks on each POST and PUT request for a CSRF token to prevent attacks. While this is great to have enabled by default, it may also break your application. The implementation of this filter is the play.filters.csrf.CSRFFilter class. Unless you have a reason to disable it, this is a good control to use in your application, but you should certainly test things thoroughly if you've never enabled it before.

The CSRFFilter is enabled by default. To disable it globally (assuming you have a good reason), you would disable the CSRF filter in application.conf:

play.filters.disabled += play.filters.csrf.CSRFFilter

The default behavior for Play is to perform anti-CSRF checks for HTTP POST and PUT form requests to prevent attackers from duping victims into executing unauthorized transactions. The checks kick in when there is either a Cookie or Authorization header present within the HTTP request. You can add additional headers that will trigger the CSRF checks within the application.conf file in the play.http.headers configuration:

protectHeaders {
  Cookie = "*"
  Authorization = "*"
  YourCustomHeader = "*"
}

You can disable CSRF checks by implementing bypass headers. The presence of such a header would disable the framework's CSRF checks, which could have security consequences if implemented incorrectly. If an attacker could spoof one of these headers from a victim, it could be used to evade the framework's CSRF controls. Proceed with caution and understand the impact of enabling this behavior.

play.filters.csrf.header.bypassHeaders {
  X-Requested-With = "*"
  Security-Magic = "true"
  Csrf-Token = "nocheck"
}

Also, if your Cross Origin Resource Sharing (CORS) filter comes before your CSRF filter, the CSRF filter will allow CORS requests to succeed if they originate from a trusted domain. This can allow for a bypass of expected CSRF protections. If you want to disable this behavior, set the following within your application.conf:

play.filters.csrf.bypassCorsTrustedOrigins = false

Security Headers

Finally, we have the Security Headers filter. This filter sets several HTTP security headers that instruct the browser to implement security controls. The following list shows the default headers that are enabled and their default settings:

  • play.filters.headers.frameOptions = DENY
  • play.filters.headers.xssProtection = 1; mode=block
  • play.filters.headers.contentTypeOptions = nosniff
  • play.filters.headers.permittedCrossDomainPolicies = master-only
  • play.filters.headers.referrerPolicy = origin-when-cross-origin, strict-origin-when-cross-origin
  • play.filters.headers.contentSecurityPolicy = default-src ‘self’

Setting a header's value to null will disable it.

Of the security headers that are set by default, Content Security Policy may cause breaking changes to your application. The default setting (`default-src 'self') will block loading content and scripts in the user's browser if they originate from a different domain. As modern applications typically load JavaScript, CSS, images, and other content from third-party domains, you may run into unexpected issues as a result.

While we at nVisium believe in the power of CSP, we also acknowledge that it is non-trivial to implement and manage in production. If you want to take the plunge into CSP, this is your time to build a robust policy that will enhance security for your users. Tools such as Report URI make it easier than ever to build a policy.

While it is not natively supported in the 2.6 implementation, CSP also features Report-Only mode where violations are logged to a reporting URI but do not result in blocked functionality at the browser. Starting out in Report-Only mode allows you to build a profile for how CSP fits your application, and tune your policy before potentially negatively impacting your users. You can still create a custom HTTP header as Content-Security-Policy-Report-Only and apply the same CSP policy within the header's value. This will achieve the same outcome, but it's not as elegant as if it were supported natively. You will also need to specify a reporting URI, which will serve as the endpoint for logging all CSP violations across your user base.

val result = Ok("Great!")
                .withHeaders(
                  "Content-Security-Policy-Report-Only",
                  "default-src ‘self’; report-uri https://yourdomain.com/endpoint;"
                )

If you are not ready to use CSP or you want to enable Report-Only mode first before you begin actively blocking violations, set play.filters.headers.contentSecurityPolicy to null.

Allowed Hosts

The primary goal of the Allowed Hosts filter is to limit the risk of DNS Rebinding Attacks. Depending on how your application is deployed or accessed, this can be breaking behavior in your environment with unanticipated consequences. Be sure to test things thoroughly as you move between environments.

With the Allowed Hosts filter enabled, we can configure it in application.conf in the play.filters.hosts.allowed configuration to limit access to a specified subset of hosts.

# Allow requests to example.com, its subdomains, localhost:9000, etc.
play.filters.hosts.allowed =  ["trustedhost.com", "10.10.100.12"]

Session

Play 2.6 introduced several changes to session management, the most significant being JSON Web Token (JWT) becoming the standard session format. There is also support for additional cookie security attributes and modes to enhance security by leveraging emerging browser security standards.

Play has two types of session scopes: Session and Flash. Session scope allows you to associate state and other data with the lifetime of a user’s valid session. Flash scope allows you to maintain state between requests, but is not persistent otherwise. The available updates for 2.6 such as JWT as well as header attributes, are available across both scope types interchangeably.

JSON Web Token (JWT)

Play 2.6 migrated to JWT and by default, uses it for its session. If you’re not familiar with JWT (some people even call them Jots), it’s a standard that allows for transmitting information between parties in a trusted way through verification and signing. Play’s JWT implementation is signed by default with HMAC-SHA-256 and Base64 encoded.

Signing prevents tampering, but does not provide confidentiality of data. While TLS provides confidentiality of communications over a network, a user, developer, or attacker can still view the contents of JWT at the browser or within an intercepting proxy. By default, JWT Base64 encodes data and doesn’t encrypt it. If you pass sensitive information within your JWT, you may want to reconsider sending that data or encrypting the payloads themselves.

JWT Settings

There are several application.conf settings that you can use to configure your JWT implementation. Here are the available options and a brief description of how they work:

  • play.http.session.jwt.signatureAlgorithm - Specifies the algorithm; we provide a list in the Implementing JWT section.
  • play.http.session.jwt.expiresAfter - Sets the JWT's expiration time.
  • play.http.session.jwt.clockSkew - The amount of time to tolerate for difference in time between clocks at the implementer and clients.
  • play.http.session.jwt.dataClaim - Claim key where user data is stored.

To see some of the tests and expected conditions for JWT that mirror expected usage and scenarios, take a look at CookiesSpec: CookiesSpec.scala

Implementing JWT

By default, HS256 is used as the signing algorithm for JWTs. However, you can use other algorithms as referenced in RFC7515. Signing your token increases trust and prevents unauthorized tampering with the values contained within it.

The supported algorithms map to the alg parameter value in the JWT specification. Within RFC7518, the following algorithms are specified for signing your JWTs:

  • HS256 - HMAC using SHA-256
  • HS384 - HMAC using SHA-384
  • HS512 - HMAC using SHA-512
  • RS256 - RSASSA-PKCS1-v1_5 using SHA-256
  • RS384 - RSASSA-PKCS1-v1_5 using SHA-384
  • RS512 - RSASSA-PKCS1-v1_5 using SHA-512
  • ES256 - ECDSA using P-256 and SHA-256
  • ES384 - ECDSA using P-384 and SHA-384
  • ES512 - ECDSA using P-521 and SHA-512
  • PS256 - RSASSA-PSS using SHA-256 and MGF1 with SHA-256
  • PS384 - RSASSA-PSS using SHA-384 and MGF1 with SHA-384
  • PS512 - RSASSA-PSS using SHA-512 and MGF1 with SHA-512
  • none - No digital signature or MAC performed; however, Play throws a 500 exception if you attempt to do this, and you don't want to do this anyway...

If you are using an HMAC-based signature, then you should have minimal work to do compared to utilizing RSA or Elliptic-curve signatures. As an example, configuring your signatureAlgorithm to use PS512 results in the following exception:

java.lang.IllegalArgumentException: Base64-encoded key bytes may only be specified for HMAC signatures.  If using RSA or Elliptic Curve, use the signWith(SignatureAlgorithm, Key) method instead.
    at io.jsonwebtoken.lang.Assert.isTrue(Assert.java:38)
    at io.jsonwebtoken.impl.DefaultJwtBuilder.signWith(DefaultJwtBuilder.java:105)
    at play.api.mvc.JWTCookieDataCodec$JWTFormatter.format(Cookie.scala:705)
    at play.api.mvc.JWTCookieDataCodec.encode(Cookie.scala:564)
    at play.api.mvc.JWTCookieDataCodec.encode$(Cookie.scala:562)
    at play.api.mvc.DefaultJWTCookieDataCodec.encode(Cookie.scala:755)
    at play.api.mvc.FallbackCookieDataCodec.encode(Cookie.scala:729)
    at play.api.mvc.FallbackCookieDataCodec.encode$(Cookie.scala:728)
    at play.api.mvc.DefaultSessionCookieBaker.encode(Session.scala:95)
    at play.api.mvc.CookieBaker.encodeAsCookie(Cookie.scala:414)
    at play.api.mvc.CookieBaker.encodeAsCookie$(Cookie.scala:413)
    at play.api.mvc.DefaultSessionCookieBaker.encodeAsCookie(Session.scala:95)
    at play.api.mvc.Result.$anonfun$bakeCookies$2(Results.scala:281)
    at scala.Option.map(Option.scala:146)

JWT tokens are signed utilizing the application’s secret that is referenced (but hopefully not hardcoded) in your application.conf file.

# Don't do this!
play.http.secret.key = "str0ngcrypt0?"

It is important to protect this value as it can be utilized to impersonate other users by forging the signature if it falls into the hands of an attacker. We recommend you store this value in a key manager or secrets storage solution. Avoid committing a hard-coded value to your source code repository as this is not a recommended practice.

Secrets management is a complex topic and greatly varies with mileage depending on your technology stack. If you are looking for a better way to store secrets on a platform like AWS, check out this awesome post by Evan Johnson on the Segment blog.

When a client receives a JWT within an HTTP response, it's received within a Set-Cookie header:

JWT Cookie Header
JWT as a cookie

This value can be Base64 decoded to identify the values stored within:

JWT Decoded
Decoded Base64 JWT in Burp Decoder

As JWTs are signed by default, if a user tampers with these values, it should fail subsequent checks and will be discarded by the web server.

JWT Tampering 500
Tampering with JWT and receiving an HTTP 500 error

Legacy Cookies

The legacy cookie format is signed using HMAC-SHA1. The CookieSigner class defines this behavior for the legacy approach.

The example below shows the play.api.libs.crypto.DefaultCookieSigner class and the legacy method of generating and signing cookies:

/**
 * Uses an HMAC-SHA1 for signing cookies.
 */
class DefaultCookieSigner @Inject() (secretConfiguration: SecretConfiguration) extends CookieSigner {

  private lazy val HmacSHA1 = "HmacSHA1"

  /**
   * Signs the given String with HMAC-SHA1 using the given key.
   *
   * By default this uses the platform default JSSE provider.  This can be overridden by defining
   * `play.http.secret.provider` in `application.conf`.
   *
   * @param message The message to sign.
   * @param key The private key to sign with.
   * @return A hexadecimal encoded signature.
   */
  def sign(message: String, key: Array[Byte]): String = {
    val mac = secretConfiguration.provider.fold(Mac.getInstance(HmacSHA1))(p => Mac.getInstance(HmacSHA1, p))
    mac.init(new SecretKeySpec(key, HmacSHA1))
    Codecs.toHexString(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)))
  }

  /**
   * Signs the given String with HMAC-SHA1 using the application’s secret key.
   *
   * By default this uses the platform default JSSE provider.  This can be overridden by defining
   * `play.http.secret.provider` in `application.conf`.
   *
   * @param message The message to sign.
   * @return A hexadecimal encoded signature.
   */
  def sign(message: String): String = {
    sign(message, secretConfiguration.secret.getBytes(StandardCharsets.UTF_8))
  }

}

If you’re not ready to make the change to JWT, you can still use the legacy session token format:

play.modules.disabled+="play.api.mvc.CookiesModule"
play.modules.enabled+="play.api.mvc.LegacyCookiesModule"

JWT Validation and Expiration

In order to control the lifetime of your tokens, you may want to ensure that you have checks in place to prevent using a token too early or after expiration. When cookies are created, the following claims are created with regards to validity:

iat - issued at; prevents using a cookie before the current time

nbf - not before; JWT claims to enforce expiration and to prevent ahead of time usage

Setting a value for play.http.session.maxAge will propagate to the play.http.session.jwt.expiresAfter setting in order to invalidate the JWT after a specified duration.

SameSite Cookies

The SameSite cookie attribute is used to mitigate session risks associated with Cross Site-Scripting (XSS) and Cross-Site Request Forgery (CSRF) attacks. The attribute was first introduced within a 2016 IETF draft, and at the time this post was written, has been adopted by Chrome (since version 58), Opera (since version 46), and Android browsers with support for Edge, Safari, and other browsers eventually to follow.

The SameSite attribute can be used for both Session and Flash cookies. To enable the attribute, you must add either play.http.session.sameSite or play.http.flash.sameSite to application.conf. The value for the attribute can be one of the following:

  • None - Business as usual; cookies can be sent even for requests originating off-site.
  • Strict - Most stringent mode; cookies are only sent for requests made from the same site.
  • Lax - Cookies are sent for only certain types of cross-domain requests; prevents some attacks, but not all.

From a protection perspective, Strict offers you the greatest amount of control because it reduces the scope of request forgery attacks to originate from the same site only. You still need proper CSRF mitigation controls (as discussed later in this post), but this further reduces the likelihood of attack if used correctly.

As an example, if you wanted to configure your default session cookie to use a Strict SameSite policy, you would add the following to your application.conf:

play.http.session.sameSite = “strict”

Cookie Prefixes

Play 2.6 also supports Cookie Prefixes, which enhance cookie and session security by signaling to the browser that various security controls should be enforced. The following prefixes are supported and understood by Play:

  • __Host - Tells the browser that the Cookie Secure attribute and the Path attribute are required, but the Domain attribute cannot be used in conjunction.
  • __Secure - Tells the browser that the Cookie secure attribute is required.

To utilize cookie prefixes, you must set a cookie named with one of the prefixe within an HTTP response to the client. This can be performed by setting the cookie on an HTTP response:

Ok("").withCookies(Cookie("__Secure-NVISIUM", superRandom, Some(1000), "/", None, true, true, Some(Cookie.SameSite.Strict)))

When a cookie prefix is provided, Play parses the prefix and checks the provided attributes to ensure they match the expected cookie prefix requirements. The implementation for this is found in the object play.api.mvc.Cookie:

/**
   * Check the prefix of this cookie and make sure it matches the rules.
   *
   * @return the original cookie if it is valid, else a new cookie that has the proper attributes set.
   */
  def validatePrefix(cookie: Cookie): Cookie = {
    val SecurePrefix = "__Secure-"
    val HostPrefix = "__Host-"
    @inline def warnIfNotSecure(prefix: String): Unit = {
      if (!cookie.secure) {
        logger.warn(s"$prefix prefix is used for cookie but Secure flag not set! Setting now. Cookie is: $cookie")(SecurityMarkerContext)
      }
    }

    if (cookie.name startsWith SecurePrefix) {
      warnIfNotSecure(SecurePrefix)
      cookie.copy(secure = true)
    } else if (cookie.name startsWith HostPrefix) {
      warnIfNotSecure(HostPrefix)
      if (cookie.path != "/") {
        logger.warn(s"""$HostPrefix is used on cookie but Path is not "/"! Setting now. Cookie is: $cookie""")(SecurityMarkerContext)
      }
      cookie.copy(secure = true, path = "/")
    } else {
      cookie
    }
  }

Route Modifier Tags

Play 2.6 introduced Route Modifier tags that allow developers to annotate routes to affect behavior. For example, we can use route modifiers to disable security controls, such as those use to prevent Cross Site Request Forgery (CSRF). By default, Play provides the nocsrf route modifier, and allows you to define and apply your own custom route modifiers.

The nocsrf route modifier is pretty self-explanatory - it disables CSRF checks for the decorated route. Your code review methodology should be updated to ensure you are also parsing the routes file to check for the presence of the nocsrf route modifier. This includes your manual code process (both developer peer reviews and security reviews) as well as automated tooling that analyzes code and configuration files.

+ nocsrf
POST  /api/v1/save              controllers.UserController.save

Multiple route modifiers can be used on a single route. The following tests from RoutesFileParserSpec.scala provide insight into the rules used to validate and build route modifiers:

"parse a modifier tag with a route" in {
  parseRoute("+nocsrf\nGET /s p.c.m").modifiers must containTheSameElementsAs(Seq(Modifier("nocsrf")))
}

"parse multiple modifiers with a route" in {
  parseRoute("+ nocsrf foo=bar\nGET /s p.c.m").modifiers must           containTheSameElementsAs(
    Seq(Modifier("nocsrf"), Modifier("foo=bar")))
}

"parse multiple modifiers where the only separator is whitespace" in {
  parseRoute("+ no+csrf foo=bar\nGET /s p.c.m").modifiers must containTheSameElementsAs(
    Seq(Modifier("no+csrf"), Modifier("foo=bar")))
}

"parse modifiers followed by comments" in {
  val route = parseRoute("+ nocsrf api # turn off csrf check\nGET /s p.c.m")
    route.modifiers must containTheSameElementsAs(Seq(Modifier("nocsrf"), Modifier("api")))
      route.comments must containTheSameElementsAs(Seq(Comment(" turn off csrf check")))
}

"parse multiple modifier lines mixed with comments on a route" in {
  val route = parseRoute("+nocsrf\n # set foo to bar \n +foo=bar\nGET /s p.c.m")
  route.modifiers must containTheSameElementsAs(Seq(Modifier("nocsrf"), Modifier("foo=bar")))
  route.comments must containTheSameElementsAs(Seq(Comment(" set foo to bar ")))
}

Security Logging

Starting in Play 2.6, by default certain security events triggered by the framework's security controls are logged at the WARN level. This is a good thing as it increases the security auditability of your code without requiring any additional work. While this may sound all-encompassing and extensive on the surface, at least initially, these checks cover a pretty small part of an application's attack surface. It is important to know what the framework will provide in terms of basic security logging and what your developers will still need to ensure you have adequate audit coverage for in your code.

Logging statements that leverage the security logging infrastructure implement SecurityMarkerContext. Here's an example of how it is implemented in the framework to log when an invalid CSRF token is detected within the apply function of play.filters.csrf.CSRFAction.scala:

def apply(untaggedRequest: RequestHeader): Accumulator[ByteString, Result] = {
    val request = csrfActionHelper.tagRequestFromHeader(untaggedRequest)

    // this function exists purely to aid readability
    def continue = next(request)

    // Only filter unsafe methods and content types
    if (config.checkMethod(request.method) && config.checkContentType(request.contentType)) {

      if (!csrfActionHelper.requiresCsrfCheck(request)) {
        continue
      } else {

        // Only proceed with checks if there is an incoming token in the header, otherwise there's no point
        csrfActionHelper.getTokenToValidate(request).map { headerToken =>

          // First check if there's a token in the query string or header, if we find one, don't bother handling the body
          csrfActionHelper.getHeaderToken(request).map { queryStringToken =>

            if (tokenProvider.compareTokens(headerToken, queryStringToken)) {
              filterLogger.trace("[CSRF] Valid token found in query string")
              continue
            } else {
              filterLogger.warn("[CSRF] Check failed because invalid token found in query string: " +
                request.uri)(SecurityMarkerContext)
              checkFailed(request, "Bad CSRF token found in query String")
            }

          } getOrElse {

            // Check the body
            request.contentType match {
              case Some("application/x-www-form-urlencoded") =>
                filterLogger.trace(s"[CSRF] Check form body with url encoding")
                checkFormBody(request, next, headerToken, config.tokenName)
              case Some("multipart/form-data") =>
                filterLogger.trace(s"[CSRF] Check form body with multipart")
                checkMultipartBody(request, next, headerToken, config.tokenName)
              // No way to extract token from other content types
              case Some(content) =>
                filterLogger.warn(s"[CSRF] Check failed because $content for request " + request.uri)(SecurityMarkerContext)
                checkFailed(request, s"No CSRF token found for $content body")
              case None =>
                filterLogger.warn(s"[CSRF] Check failed because request without content type for " + request.uri)(SecurityMarkerContext)
                checkFailed(request, s"No CSRF token found for body without content type")
            }

          }
        } getOrElse {

          filterLogger.warn("[CSRF] Check failed because no token found in headers for " + request.uri)(SecurityMarkerContext)
          checkFailed(request, "No CSRF token found in headers")

        }
      }
    } else if (csrfActionHelper.getTokenToValidate(request).isEmpty && config.createIfNotFound(request)) {

      // No token in header and we have to create one if not found, so create a new token
      val requestWithNewToken = csrfActionHelper.tagRequestHeaderWithNewToken(request)

      // Once done, add it to the result
      next(requestWithNewToken).map(csrfActionHelper.addTokenToResponse(requestWithNewToken, _))

    } else {
      filterLogger.trace("[CSRF] No check necessary")
      next(request)
    }
  }

After searching through the source code for all references to SecurityMarkerContext, here are the security events that trigger a logging statement and their associated messages:

  • CSRF
    • Check failed because invalid token found in query string
    • Check failed because $content for request " + request.uri
    • Check failed because request without content type for " + request.uri
    • Check failed because $content request
    • Check failed because request without content type
    • Check failed because no token found in headers
    • Check failed because no or invalid token found in body
    • Check failed with NoTokenInBody
    • CSRF token check failed
  • CORS
    • Invalid CORS request;Origin=${request.headers.get(HeaderNames.ORIGIN)};Method=${request.method};${HeaderNames.ACCESS_CONTROL_REQUEST_HEADERS}=${request.headers.get(HeaderNames.ACCESS_CONTROL_REQUEST_HEADERS)}
  • JWT
    • decode: premature JWT found! id = $id, message = ${e.getMessage}
    • decode: expired JWT found! id = $id, message = ${e.getMessage}
    • decode: could not decode JWT: ${e.getMessage}
  • Cookie
    • $HostPrefix is used on cookie but Path is not "/"! Setting now. Cookie is: $cookie
    • Cookie failed message authentication check
    • decode: cookie has invalid signature! message = ${e.getMessage}
    • Could not decode cookie
  • Allowed Hosts
    • Host not allowed: ${req.host}

If you need to disable security logging for any reason, you can do this through the logback.xml file:

<turboFilter class="ch.qos.logback.classic.turbo.MarkerFilter">
    <Marker>SECURITY</Marker>
    <OnMatch>DENY</OnMatch>
</turboFilter>

You still need to log events such as authorization or authentication failures, as well as failed input validation, attempted SQL Injection attacks, failed business logic routines, and so on. As we've seen, there are only a handful of security events that get logged by default through the framework and you are responsible for implementing the rest. If you're looking for broader guidance on how and what to log for auditing security events within your systems, check out the OWASP Logging Cheat Sheet.

Request Attributes

Prior to Play 2.6, request tags were used to persist basic user information within their session as additional data. However, the only supported format type was String, limiting the use cases for it. Request Attributes were introduced in 2.6, and extended this capability to include objects and complex types. Tags are considered a deprecated feature moving forward and all developers are encouraged to migrate to Request Attributes.

Using Request Attributes is easy, and requires minimal re-coding of your existing tags.

// Using a Request Attribute
// https://www.playframework.com/documentation/2.6.x/Highlights26#request-attributes

object Attrs {
  val User: TypedKey[User] = TypedKey.apply[User]("user")
}
// Get the User object from the request
val user: User = req.attrs(Attrs.User)
// Put a User object into the request
val newReq = req.addAttr(Attrs.User, newUser)

// Deprecated approach with Request Tags
val email: String = req.tags("email")
val optEmail: Option[String] = req.tags.get("email")
val newReq = req.copy(tags = req.tags.updated("email", updatedEmail))

In general, you should avoid persisting anything overly sensitive within Request Attributes in plain text, such as passwords, credit card numbers, Social Security numbers, and other information that people like to steal. You should also avoid storing unnecessarily large objects, as this could become a Denial-of-Service (DoS) vector should an attacker can exhaust considerable resources.

Request Attributes are considered a trust boundary, and in general, you should avoid allowing client-side interactions to set this information. You should use it for items that require quick lookups where you want to avoid database round-trip times. But you should avoid allowing client-side values to overwrite sensitive values on the backend such as user ID, organization ID, or any other identifiers that may allow a user to perform unauthorized actions.

Conclusion

Overall, Play 2.6 introduced several security enhancements and features to let you take better control of your security posture. As we've seen, it's important to understand what the framework does for us because we're still responsible for securing ourselves. As you migrate to Play 2.6, try to take advantage of as many of these new features as you can!