JWT prevents hot linking to your media

Imagine you have some media files published (static http(s) links) on your website for targeted customers, which have been very popular recently. Other sites (search engine) started finding links of your media and putting it on their websites or people started sharing your media links with others. Suddenly you see surge of download on your website, costing your bandwidth.

To avoid this situation, one approach could be to sign media URLs when clients ask for it and give temporary access to private resources without requiring additional authorization. A signed URL can be used by anyone who has it, so to minimize the risk of a signed URL being shared, set it to expire as soon as possible.

In Windows Azure this feature is called Shared Access Signature(SAS) and in AWS, we call it “Signed Url”

How URLs are signed — Using JWT

JSON Web Tokens (JWT) are a compact, self-contained way for securely transmitting information and represent claims between parties as a JSON object. JWT consists 3 components: HeaderPayload, and Signaturedelimited by a.(period) character. More details

JWTs can also be signed using a secret (with HMAC algorithm) OR a public/private key pair using RSA.

Express Media Server — hosts media related APIs

When client makes API call (http(s)://…/api/mediadetail/{1}) to request media file path, the MediaController – uses uriTokenizer (based on JsonWebToken) module creates temporary JWT (has limited expiry time — ex. 3 seconds) and append it with media Url and publish it to client.

Also route to path (/file) is protected with uriTokenizer.verifyUriRequestmiddle-ware.

const mediaController = require('./Controllers/MediaController');
const uriTokenizer = require('./utils/uriTokenizer'); //Reference to uriTokenizer utility
var tokenizer = new uriTokenizer( {algorithm: 'RS256', expiresIn : '15m' }, { algorithms: ['RS256'] }); //Setting 15 minutes expiry

var appApi = express();
....
// Route setup
appApi.use('/file', tokenizer.verifyUriRequest, express.static(path.join(__dirname, '/assets/mediafiles')));
appApi.use("/api/mediadetail", jwtCheck, mediaController); //media contreoller uses uriTokenizer to append a token with the url

Routes on Media API Server

Media route controller — “/api/mediadetail”

Fetches media results from db and build signed media url.

let _ = require('lodash');
const uriTokenizer = require('../utils/uriTokenizer');
const tokenizer = new uriTokenizer( { algorithm: 'RS256', expiresIn : '15m' }, { algorithms: ['RS256'] });
const media =  require('../Model/Media');

async function buildMediaFileDownloadUrl(req, mediafile){
    let uri= req.protocol + '://' + req.get('host') + '/file/' + (mediafile || "").replace(/^\//, '');    
    return await tokenizer.signUri(uri, { divison : "Honda"});
}

router.get('/:fsno', (req,res,next) =>
{
    if(req.params.fsno){
        media.getMediaFileByFsNo(req.params.fsno, function(err,rows){
            if(err) next(err);
            else
            {
                let filesPromises = _.map(rows, async function(value){
                    return {
                        url: await buildMediaFileDownloadUrl(req, value.DownloadPath),
                        size: parseInt(value.Size),
                        destinationFolder: value.RelativeDestFolder,
                    };
                });
                Promise.all(filesPromises).then(
                    function(result){ 
                        var firmwareFilesData = { results:{
                            totalSize: _.sumBy(rows, function(o) { return o.Size; }),
                            folderStruct : _.map(_.uniq(_.filter(rows, function(o) { return o.RelativeDestFolder && o.RelativeDestFolder.length !=''; })),
                                function(value){
                                    return value.RelativeDestFolder
                                }
                            ),
                            files : result
                        }};
                        res.json(firmwareFilesData); 
                    },
                    function error(err){ next(err); }
                );
            }
        });
    }
});
module.exports = router;

A typical JWT signed url looks like

https://domain/file/../nodejs.mp3?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1MWQ4NGFjMS1kYjMxLTRjM2ItOTQwOS1lNjMwZWJiYjgzZGYiLCJ1c2VybmFtZSI6Imh1bnRlcjIiLCJzY29wZXMiOlsicmVwbzpyZWFkIiwiZ2lzdDp3cml0ZSJdLCJpc3MiOiIxNDUyMzQzMzcyIiwiZXhwIjoiMTQ1MjM0OTM3MiJ9.cS5KkPxtEJ9eonvsGvJBZFIamDnJA7gSz3HZBWv6S1Q

When client accesses media files, using signed URLs. Each access to a media file triggers token validation by the middle-ware on server . If a token is present, valid & not expired, access to the media file is granted or client receives 403 http error.

uriTokenizer utility used by Express server to sign and verify requests to signed media

const fs = require('fs');
const config = require('../assets/configs/config');
const jwt = require('jsonwebtoken');
const urlBuilder = require('build-url');

//We use Public/Private key, but you can use Secret (with HMAC algorithm)
let secretOrPrivateKey = fs.readFileSync(config.sslPrivatekey_path);
let secretOrPublicKey = fs.readFileSync(config.sslPublickey_path);

function uriTokenizer(signOptions, verifyOptions){
    // Sign options incluses
    // algorithm, keyid, expiresIn, notBefore, audience, subject, issuer, jwtid, noTimestamp, header, encoding
    this.signOptions= signOptions; 
    
    // Verify options incluses
    // algorithms, audience, clockTimestamp, clockTolerance, issuer, ignoreExpiration, ignoreNotBefore, jwtid, subject
    this.verifyOptions = verifyOptions;
}

//To sign a Uri with any payload provided
uriTokenizer.prototype.signUri = async function (uri, payload){
    let that=this;
    const promise = new Promise(function(resolve, reject) {
        jwt.sign(payload, secretOrPrivateKey, that.signOptions, function(err, token) {
            if(err) 
                return reject(err);

            return resolve(
                urlBuilder(uri, {queryParams: {token: token }}); //append Toekn query string with the Uri
            );
        });
    });
    return await promise;
};

//An async middleware to verify if a token passed is valid & not expired.
uriTokenizer.prototype.verifyUriRequest = async function (req,res,next){
    const token = req.query.token;
    if (!token) //if token is missing
        return res.status(400).json({ message: config.TOKEN_NOT_FOUND_MESSAGE });

    jwt.verify(token, secretOrPublicKey, this.verifyOptions, (err, payload) => {
        //return 403 in case of TokenExpiredError/JsonWebTokenError
        if(err) return res.status(403).json({ message: err.message });
        return next();
    });
};

module.exports = uriTokenizer;

This mechanism prevents user from opening the static media URL without the access of a token. Even though if someone copies a short lived token, there is subtle chance of reusing it.

Stay tune for the next blog post on one time download link.

2 thoughts on “JWT prevents hot linking to your media

Leave a Reply to Sudip Purkayastha Cancel reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s