Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Leverage HTTP to deliver cacheable websites - Techorama 2017

Leverage HTTP to deliver cacheable websites - Techorama 2017

Ca901ddcea38854b9783781c91fc87c9?s=128

Thijs Feryn

May 23, 2017
Tweet

Transcript

  1. Leverage HTTP to deliver cacheable websites By Thijs Feryn

  2. Caching

  3. Don’t recompute if the data hasn’t changed

  4. Hi, I’m Thijs

  5. I’m @ThijsFeryn on Twitter

  6. I’m an Evangelist At

  7. None
  8. Afterthought

  9. What if we could design our software with HTTP caching

    in mind?
  10. ✓Portability ✓Developer empowerment ✓Control ✓Consistent caching behavior Caching state of

    mind
  11. None
  12. None
  13. Reverse caching proxy

  14. Normally User Server

  15. With ReCaPro * User ReCaPro Server * Reverse Caching Proxy

  16. None
  17. None
  18. None
  19. None
  20. Any other CDN

  21. Speaks HTTP

  22. HTTP caching mechanisms Expires: Sat, 09 Sep 2017 14:30:00 GMT

    Cache-control: public, max-age=3600, s-maxage=86400 Cache-control: private, no-cache, no-store
  23. In an ideal world

  24. ✓Stateless ✓Idempotent ✓Well-defined TTL ✓Cache / no-cache per resource ✓Cache

    variations ✓Conditional requests In an ideal world
  25. Reality sucks

  26. Common problems

  27. None
  28. None
  29. Time To Live

  30. Cache variations

  31. Authentication

  32. Legacy

  33. Introducing the demo app

  34. None
  35. Silex framework

  36. Twig templates

  37. ✓Multi-lingual (Accept-Language) ✓Nav ✓Header ✓Footer ✓Main ✓Login page & private

    content Introducing the app
  38. None
  39. <?php require_once __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\RedirectResponse; use

    Symfony\Component\HttpFoundation\Request; use Symfony\Component\Translation\Loader\YamlFileLoader; $app = new Silex\Application(); $app['locale'] = 'en'; $app->register(new Silex\Provider\TwigServiceProvider(), ['twig.path' => __DIR__.'/../views']); $app->register(new Silex\Provider\TranslationServiceProvider(), ['locale_fallbacks' => ['en','nl']]); $app->register(new Silex\Provider\SessionServiceProvider()); $app->extend('translator', function($translator, $app) { $translator->addLoader('yaml', new YamlFileLoader()); $translator->addResource('yaml', dirname(__DIR__).'/locales/en.yml', 'en'); $translator->addResource('yaml',dirname( __DIR__).'/locales/nl.yml', 'nl'); return $translator; }); $app['credentials'] = [ 'admin' => '$2y$10$431rvq1qS9ewNFP0Gti/o.kBbuMK4zs8IDTLlxm5uzV7cbv8wKt0K' ]; $app->before(function (Request $request) use ($app){ $request->setLocale($request->getPreferredLanguage()); $app['translator']->setLocale($request->getPreferredLanguage()); }); $app->after(function(Request $request, Response $response) use ($app){ $response->headers->set('Content-Length',strlen($response->getContent())); });
  40. $app->get('/', function () use($app) { if($app['session']->has('username')) { $loginLogoutUrl = $app['url_generator']->generate('logout');

    $loginLogoutLabel = 'log_out'; } else { $loginLogoutUrl = $app['url_generator']->generate('login'); $loginLogoutLabel = 'log_in'; } $response = new Response($app['twig']->render('index.twig', ['loginLogoutUrl'=>$loginLogoutUrl,'loginLogoutLabel'=>$loginLogoutLabel]),200); return $response; })->bind('home'); $app->get('/login', function (Request $request) use($app) { if($app['session']->has('username')) { return new RedirectResponse($app['url_generator']->generate('home')); } $response = new Response($app['twig']->render('login.twig'),200); return $response; })->bind('login'); $app->get('/logout', function () use($app) { $response = new RedirectResponse($app['url_generator']->generate('login')); $app['session']->invalidate(); return $response; })->bind('logout'); Routes
  41. <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/html" xmlns:hx="http://purl.org/NET/hinclude">
 <head>
 <title>{% block title %}{%

    endblock %} - Developing cacheable websites</title>
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
 <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/ bootstrap.min.css" integrity="sha384- BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
 <script src="//rawgit.com/mnot/hinclude/master/hinclude.js"></script>
 <script
 src="https://code.jquery.com/jquery-2.2.4.min.js"
 integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44="
 crossorigin="anonymous"></script>
 <script src="//rawgit.com/carhartl/jquery-cookie/master/src/jquery.cookie.js"></ script>
 </head>
 <body>
 <div class="container-fluid">
 {{ include('header.twig') }}
 <div class="row">
 <div class="col-sm-3 col-lg-2">
 {{ include('nav.twig') }}
 </div>
 <div class="col-sm-9 col-lg-10">
 {% block content %}{% endblock %}
 </div>
 </div>
 {{ include('footer.twig') }}
 </div>
 </body>
 </html> Base template
  42. {% extends "base.twig" %}
 {% block title %}Home{% endblock %}


    {% block content %}
 <div class="page-header">
 <h1>{{ 'example' | trans }} <small>{{ 'rendered' | trans({'%date%':"now"|date("Y- m-d H:i:s")}) }}</small></h1>
 </div>
 <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci eget libero sollicitudin, non ultrices turpis mollis. Aliquam sit amet tempus elit. Ut viverra risus enim, ut venenatis justo accumsan nec. Praesent a dolor tellus. Maecenas non mauris leo. Pellentesque lobortis turpis at dapibus laoreet. Mauris rhoncus nulla et urna mollis, et lobortis magna ornare. Etiam et sapien consequat, egestas felis sit amet, dignissim enim.</p>
 <p>Quisque quis mollis justo, imperdiet fermentum velit. Aliquam nulla justo, consectetur et diam non, luctus commodo metus. Vestibulum fermentum efficitur nulla non luctus. Nunc lorem nunc, mollis id efficitur et, aliquet sit amet ante. Sed ipsum turpis, vehicula eu semper eu, malesuada eget leo. Vestibulum venenatis dui id pulvinar suscipit. Etiam nec massa pharetra justo pharetra dignissim quis non magna. Integer id convallis lectus. Nam non ullamcorper metus. Ut vestibulum ex ut massa posuere tincidunt. Vestibulum hendrerit neque id lorem rhoncus aliquam. Duis a facilisis metus, a faucibus nulla.</p>
 {% endblock %} Home page template
  43. <div class="jumbotron">
 <div class="page-header">
 <h1>{{ 'welcome'|trans }} <em id="usernameLabel"></em></h1>
 <small>{{

    'rendered' | trans({'%date%':"now"|date("Y-m-d H:i:s")}) }}</ small>
 </div>
 </div> Header template
  44. <footer><hr /><small>Footer {{ 'rendered' | trans({'%date%':"now"|date("Y-m-d H:i:s")}) }}</small></footer> Footer template

  45. <nav class="navbar navbar-default navbar-fixed-side">
 <ul class="nav">
 <li><a href="{{ url('home') }}">Home</a></li>


    <li><a href="{{ loginLogoutUrl| default(url('login')) }}">{{ loginLogoutLabel|default('log_in') | trans }}</a></ li>
 <li><a href="{{ url('private') }}">Private</a></li>
 </ul>
 <small>{{ 'rendered' | trans({'%date%':"now"|date("Y-m-d H:i:s")}) }}</small>
 </nav> Navigation template
  46. home: Home
 welcome : Welcome to the site
 rendered :

    Rendered at %date%
 example : An example page
 log_in : Log in
 login : Login
 log_out : Log out
 username : Username
 password : Password
 private : Private
 privatetext : Looks like some very private data English locale file
  47. home: Start
 welcome : Welkom op de site
 rendered :

    Samengesteld op %date%
 example : Een voorbeeldpagina
 log_in : Inloggen
 login : Login
 log_out : Uitloggen
 username : Gebruikersnaam
 password : Wachtwoord
 private : Privé
 privatetext : Deze tekst ziet er vrij privé uit Dutch locale file
  48. None
  49. The mission Maximum Cacheability

  50. Cache-control

  51. $app->register(new HttpFragmentServiceProvider()); $app->register(new HttpCacheServiceProvider()); Register providers

  52. $app->get('/', function () use($app) { $response = new Response($app['twig']->render('index.twig'),200); $response

    ->setSharedMaxAge(500) ->setPublic(); return $response; })->bind('home'); Cache-Control: public, s-maxage=500
  53. $app->get('/private', function () use($app) { if(!$app['session']->has('username')) { return new RedirectResponse($app['url_generator']-

    >generate('login')); } $response = new Response($app['twig']->render('private.twig'),200); $response->headers->addCacheControlDirective('no-store', true); $response->headers->addCacheControlDirective('no-cache', true); $response ->setSharedMaxAge(0) ->setPrivate(); return $response; })->bind('private'); Cache-Control: private, no-cache, no-store, s-maxage=0
  54. Doesn't work

  55. PHPSESSID cookie No cache

  56. Block caching

  57. None
  58. Code renders single HTTP response

  59. Lowest common denominator: no cache

  60. <esi:include src="/header" /> Edge Side Includes ✓Placeholder ✓Parsed by Varnish

    ✓Output is a composition of blocks ✓State per block ✓TTL per block
  61. sub vcl_recv { set req.http.Surrogate-Capability = "key=ESI/1.0"; } sub vcl_backend_response

    { if (beresp.http.Surrogate-Control ~ "ESI/1.0") { unset beresp.http.Surrogate-Control; set beresp.do_esi = true; } } Edge Side Includes
  62. ESI vs AJAX

  63. <esi:include src="/header" /> Choose wisely <hx:include src="/header"></hx:include> ESI, parsed by

    Varnish HInclude, parsed by Javascript
  64. HttpFragmentServiceProvider ESI & HInclude support

  65. Subrequests

  66. <div class="container-fluid">
 {{ include('header.twig') }}
 <div class="row">
 <div class="col-sm-3 col-lg-2">


    {{ include('nav.twig') }}
 </div>
 <div class="col-sm-9 col-lg-10">
 {% block content %}{% endblock %}
 </div>
 </div>
 {{ include('footer.twig') }}
 </div> <div class="container-fluid">
 {{ render_esi(url('header')) }}
 <div class="row">
 <div class="col-sm-3 col-lg-2">
 {{ render_esi(url('nav')) }}
 </div>
 <div class="col-sm-9 col-lg-10">
 {% block content %}{% endblock %}
 </div>
 </div>
 {{ render_hinclude(url('footer')) }}
 </div>
  67. <div class="container-fluid"> <esi:include src="/header" /> <div class="row"> <div class="col-sm-3 col-lg-2">

    <esi:include src="/nav" /> </div> <div class="col-sm-9 col-lg-10"> <div class="page-header"> <h1>An example page <small>Rendered at 2017-05-17 16:57:14</ small></h1> </div> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci eget libero sollicitudin,…</p> </div> </div> <hx:include src="/footer"></hx:include> </div>
  68. $app->get('/header', function () use($app) { $response = new Response($app['twig']->render('header.twig'),200); $response

    ->setSharedMaxAge(500) ->setPublic(); return $response; })->bind('header'); $app->get('/footer', function () use($app) { $response = new Response($app['twig']->render('footer.twig'),200); $response ->setSharedMaxAge(500) ->setPublic(); return $response; })->bind('footer'); $app->get('/nav', function (Request $request) use($app) { if($app['session']->has('username')) { $loginLogoutUrl = $app['url_generator']->generate('logout'); $loginLogoutLabel = 'log_out'; } else { $loginLogoutUrl = $app['url_generator']->generate('login'); $loginLogoutLabel = 'log_in'; } $response = new Response($app['twig']->render('nav.twig', ['loginLogoutUrl'=>$loginLogoutUrl,'loginLogoutLabel'=>$loginLogoutLabel]),200); $response->headers->addCacheControlDirective('no-store', true); $response->headers->addCacheControlDirective('no-cache', true); $response ->setSharedMaxAge(0) ->setPrivate(); return $response; })->bind('nav'); Separate routes per block
  69. Problem: no language cache variation

  70. Vary: Accept-Language

  71. $app->after(function(Request $request, Response $response) use ($app) { $response->headers->set('Content-Length', strlen($response->getContent())); });

    $app->after(function(Request $request, Response $response) use ($app) { $response->setVary('Accept-Language',false); $response->headers->set('Content-Length', strlen($response->getContent())); });
  72. None
  73. None
  74. Improvements: conditional requests

  75. Conditional requests HTTP/1.1 200 OK Host: localhost Etag: 7c9d70604c6061da9bb9377d3f00eb27 Content-type:

    text/html; charset=UTF-8 Hello world output GET /if_none_match.php HTTP/1.1 Host: localhost User-Agent: curl/7.48.0
  76. Conditional requests HTTP/1.0 304 Not Modified Host: localhost Etag: 7c9d70604c6061da9bb9377d3f00eb27

    GET /if_none_match.php HTTP/1.1 Host: localhost User-Agent: curl/7.48.0 If-None-Match: 7c9d70604c6061da9bb9377d3f00eb27
  77. Conditional requests HTTP/1.1 200 OK Host: localhost Last-Modified: Fri, 22

    Jul 2016 10:11:16 GMT Content-type: text/html; charset=UTF-8 Hello world output GET /if_none_match.php HTTP/1.1 Host: localhost User-Agent: curl/7.48.0
  78. Conditional requests HTTP/1.0 304 Not Modified Host: localhost Last-Modified: Fri,

    22 Jul 2016 10:11:16 GMT GET /if_none_match.php HTTP/1.1 Host: localhost User-Agent: curl/7.48.0 If-Modified-Since: Fri, 22 Jul 2016 10:11:16 GMT
  79. Varnish supports conditional requests client-side & server-side

  80. $app->after(function(Request $request, Response $response) use ($app) { $response->setVary('Accept-Language',false) $response->headers->set('Content-Length', strlen($response->getContent()));

    }); $app->after(function(Request $request, Response $response) use ($app) { $response ->setETag(md5($response->getContent())) ->setVary('Accept-Language',false) ->isNotModified($request); $response->headers->set('Content-Length', strlen($response->getContent())); });
  81. ✓Navigation page ✓Private page Weak spots Not cached because of

    stateful content
  82. Move state client-side

  83. Replace PHP session with JSON Web Tokens

  84. JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pb iIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydWV9.u4Idy- SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8 ✓3 parts ✓Dot separated ✓Base64 encoded

    JSON ✓Header ✓Payload ✓Signature (HMAC with secret)
  85. eyJzdWIiOiJhZG1pbiIsIm V4cCI6MTQ5NTUyODc1Niwi bG9naW4iOnRydWV9 { "alg": "HS256", "typ": "JWT" } {

    "sub": "admin", "exp": 1495528756, "login": true } HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret ) eyJhbGciOiJIUzI1NiIsI nR5cCI6IkpXVCJ9 u4Idy- SYnrFdnH1h9_sNc4OasOR BJcrh2fPo1EOTre8
  86. JWT Cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz dWIiOiJhZG1pbiIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydW V9.u4Idy-SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8 ✓Stored in a cookie ✓Can be

    validated by Varnish ✓Payload can be processed by any language (e.g. Javascript)
  87. VCL vcl 4.0; import digest; import std; import cookie; import

    var; backend default { .host = "localhost"; .port = "8080"; } Init
  88. VCL sub vcl_recv {
 var.set("key","SlowWebSitesSuck");
 set req.url = std.querysort(req.url);
 if(req.http.accept-language

    ~ "^\s*(nl)") {
 set req.http.accept-language = regsub(req.http.accept- language,"^\s*(nl).*$","\1");
 } else {
 set req.http.accept-language = "en";
 }
 set req.http.Surrogate-Capability="key=ESI/1.0";
 if ((req.method != "GET" && req.method != "HEAD") || req.http.Authorization) {
 return (pass);
 }
 call jwt;
 if(req.url == "/private" && req.http.X-Login != "true") {
 return(synth(302,"/logout"));
 }
 return(hash);
 } Receive request
  89. VCL sub vcl_backend_response {
 set beresp.http.x-host = bereq.http.host;
 set beresp.http.x-url

    = bereq.url;
 if(beresp.http.Surrogate-Control~"ESI/1.0") {
 unset beresp.http.Surrogate-Control;
 set beresp.do_esi=true;
 return(deliver);
 }
 }
 
 sub vcl_deliver {
 unset resp.http.x-host;
 unset resp.http.x-url;
 unset resp.http.vary;
 }
 
 sub vcl_synth {
 if (resp.status == 301 || resp.status == 302) {
 set resp.http.location = resp.reason;
 set resp.reason = "Moved";
 return (deliver);
 }
 } Receive backend response Strip headers Custom redirect logic
  90. sub jwt {
 if(req.http.cookie ~ "^([^;]+;[ ]*)*token=[^\.]+\.[^\.]+\.[^\.]+([ ]*;[^;]+)*$") {
 cookie.parse(req.http.cookie);


    cookie.filter_except("token");
 var.set("token", cookie.get("token"));
 var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
 var.set("type", regsub(digest.base64url_decode(var.get("header")),{"^.*?"typ"\s*: \s*"(\w+)".*?$"},"\1"));
 var.set("algorithm", regsub(digest.base64url_decode(var.get("header")),{"^.*?"alg"\s*: \s*"(\w+)".*?$"},"\1"));
 
 if(var.get("type") != "JWT" || var.get("algorithm") != "HS256") {
 return(synth(400, "Invalid token"));
 }
 
 var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+$","\1"));
 var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+)$","\1")); var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var.get("key"),var.ge t("header") + "." + var.get("rawPayload"))));
 var.set("payload", digest.base64url_decode(var.get("rawPayload")));
 var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*(\w+).*?$"},"\1"));
 var.set("username",regsub(var.get("payload"),{"^.*?"sub"\s*:\s*"(\w+)".*?$"},"\1"));
 
 if(var.get("signature") != var.get("currentSignature")) {
 return(synth(400, "Invalid token"));
 }
 
 if(var.get("username") ~ "^\w+$") {
 if(std.time(var.get("exp"),now) >= now) { 
 set req.http.X-Login="true";
 } else {
 set req.http.X-Login="false";
 }
 }
 }
 }
  91. cookie.parse(req.http.cookie);
 cookie.filter_except("token");
 var.set("token", cookie.get("token"));
 var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
 var.set("type", regsub(digest.base64url_decode(var.get("header")), {"^.*?"typ"\s*:\s*"(\w+)".*?$"},"\1"));
 var.set("algorithm",

    regsub(digest.base64url_decode(var.get("header")), {"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1")); if(var.get("type") != "JWT" || var.get("algorithm") != "HS256") {
 return(synth(400, "Invalid token"));
 } Validate JWT header
  92. var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+ $","\1"));
 var.set("payload", digest.base64url_decode(var.get("rawPayload")));
 var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*(\w+).*?$"},"\1"));
 var.set("username",regsub(var.get("payload"),{"^.*?"sub"\s*:\s*"(\w+)".*? $"},"\1")); Get payload

  93. var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+) $","\1")); var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var. get("key"),var.get("header") + "." + var.get("rawPayload")))); if(var.get("signature") !=

    var.get("currentSignature")) {
 return(synth(400, "Invalid token"));
 } Verify signature
  94. if(var.get("username") ~ "^\w+$") {
 if(std.time(var.get("exp"),now) >= now) {
 set req.http.X-Login="true";


    } else {
 set req.http.X-Login="false";
 }
 } Verify login action Check expiration Use X- Login in vcl_recv
  95. $app['jwtKey'] = 'SlowWebSitesSuck'; $app['jwtEncode'] = function() use ($app){ return function($username)

    use ($app) { return JWT::encode([ 'sub'=>$username, 'exp'=>time() + 86400, 'login'=>true, ],$app['jwtKey']); }; }; $app['jwtValidate'] = function() use ($app) { return function($token) use ($app) { try { $data = JWT::decode($token,$app['jwtKey'],['HS256']); $data = (array)$data; if(!isset($app['credentials'][$data['sub']])) { return false; } return true; } catch(UnexpectedValueException $e) { return false; } }; }; The code Helper functions
  96. $app->get('/private', function (Request $request) use($app) { if(!$app['jwtValidate']($request->cookies->get('token'))) { return new

    RedirectResponse($app['url_generator']->generate('login')); } $response = new Response($app['twig']->render('private.twig'),200); $response ->setSharedMaxAge(500) ->setPublic(); return $response; })->bind('private'); The code Redirect to login page
  97. $app->get('/nav', function (Request $request) use($app) { if($app['jwtValidate']($request->cookies->get('token'))) { $loginLogoutUrl =

    $app['url_generator']->generate('logout'); $loginLogoutLabel = 'log_out'; } else { $loginLogoutUrl = $app['url_generator']->generate('login'); $loginLogoutLabel = 'log_in'; } $response = new Response($app['twig']->render('nav.twig', ['loginLogoutUrl'=>$loginLogoutUrl,'loginLogoutLabel'=>$loginLogoutLabel] ),200); $response ->setVary('X-Login',false) ->setSharedMaxAge(500) ->setPublic(); return $response; })->bind('nav'); The code Cache variation
  98. Bonus

  99. <script language="JavaScript">
 function getCookie(name) {
 var value = "; "

    + document.cookie;
 var parts = value.split("; " + name + "=");
 if (parts.length == 2) return parts.pop().split(";").shift();
 }
 function parseJwt (token) {
 var base64Url = token.split('.')[1];
 var base64 = base64Url.replace('-', '+').replace('_', '/');
 return JSON.parse(window.atob(base64));
 };
 $(document).ready(function(){
 if ($.cookie('token') != null ){
 var token = parseJwt($.cookie("token"));
 $("#usernameLabel").html(', ' + token.sub);
 }
 });
 </script> Parse JWT in Javascript
  100. Does not require backend access

  101. ✓Application issues JWT ✓Varnish validates JWT ✓Varnish can verify login

    ✓Varnish can redirect to login page ✓No backend access required ✓Only POST /login hits the backend ✓Even works without Varnish ✓Even Javascript can parse JWT JWT conclusion
  102. https://github.com/ThijsFeryn/ cacheable-site-silex/tree/v2

  103. ✓Cache-control ✓Accept-Language ✓Vary ✓E-Tag ✓If-none-match ✓ESI ✓AJAX (HInclude) ✓JWT Summary

  104. None
  105. None
  106. https://twitter.com/thijsferyn https://instagram.com/thijsferyn https://blog.feryn.eu https://talks.feryn.eu https://book.feryn.eu https://youtube.com/thijsferyn https://soundcloud.com/thijsferyn http://itunes.feryn.eu