JWT Decoder for Rails APIs

Paste the token your Rails API was handed, read the header and payload inside it, and take away the ruby-jwt call that would verify that same token. The decoding happens in your browser.

All tools

The token

This page decodes the token and stops there. Only your Rails app, holding the signing key, can say whether it is genuine.

Everything runs in your browser. Nothing is uploaded.

What arrives in the Authorization header

A Rails API is handed the token as one line: Authorization: Bearer, then three base64url parts joined with dots. The first part is the header and names the signing algorithm in alg. The second is the payload, a JSON object whose registered claims are the ones every library agrees on - iss for whoever issued it, sub for whoever it is about, aud for whoever is meant to accept it, jti for the token's own identifier, and iat, nbf and exp written as plain seconds since 1 January 1970 rather than as dates. The third part is the signature, raw bytes rather than text, which is why it does not decode into anything readable. Only the registered claims get a row of their own in the table above. A role, a tenant id, anything else your identity provider writes into the token, appears in the payload block and nowhere else, because nothing outside your own app knows what those mean.

Decoding is not verification

Everything above came out of base64, not out of a cipher, so anybody holding the token can read it and this page is doing nothing privileged. What none of it establishes is that the token is genuine. That is JWT.decode(token, key, true, algorithm: "HS256") - the third argument switches verification on, and the fourth pins the algorithm you are willing to accept. Leave either one out and ruby-jwt takes the algorithm from the token's own header, which is the caller's to write. A token whose header says none carries no signature at all and sails through; a token signed with the HMAC of your published RSA public key verifies against a call that will do either. Both are old, both still reach code review, and both are closed by the same two arguments.

Where the key lives in a Rails app

The signing key belongs in Rails.application.credentials, encrypted in the repository and opened by a master key the repository never holds. Reading it from ENV on the server works until somebody prints the environment into a log or a crash report, and a committed initializer or a checked-in .env has no version at which it is safe. Which key you need depends on who signs. If your own app issues the tokens it is usually HS256 with one shared secret, and that one string both signs and verifies, so everything that mints a token and everything that accepts one must hold it. If an identity provider signs them it is usually RS256, you hold only the public half, and that half is not a secret at all - fetch it from the provider's JWKS endpoint and cache it. The difference shows up on rotation: changing an HS256 secret invalidates every token already in circulation the moment the new value is live, so verify against both values for at least the lifetime of one token before dropping the old one.

Questions Rails developers ask about JWTs

Because without it ruby-jwt believes the token. The alg field sits in the header, the header is part of what the caller sent, and a decode that does not pin an algorithm honours whatever it finds there. Two well-worn attacks follow. A token whose header says none carries no signature, and a verification willing to accept none accepts it. A token signed with HMAC, using your published RSA public key as the secret, verifies against a call that is willing to do either. Pass algorithm: "HS256", or algorithms: ["RS256", "RS512"] while you are migrating between two, and pass true as the third argument so verification runs at all. The snippet on this page pins whatever the token you pasted was signed with, which is a starting point rather than an answer - pin what your app issues, not what one token happened to carry.

Rescue it. ruby-jwt compares exp against the clock on every verified decode and raises JWT::ExpiredSignature when the token is past it, so a hand-written Time.now.to_i > payload["exp"] is a second implementation of something that has already run - and it runs afterwards, which means the expired token was treated as valid for the length of one method. Rescue JWT::ExpiredSignature before JWT::DecodeError, because the first is a subclass of the second and the other order makes the expiry branch unreachable. Answer both with 401 rather than 403: the credential is the problem, not the permission. If the clocks drift between the issuer and the API, leeway: 30 is the option to reach for, rather than a wider window of your own.

In Rails.application.credentials, read as Rails.application.credentials.jwt_hmac_secret or whatever you name it, with config/master.key kept out of the repository and handed to the server by the deploy. ENV is the usual alternative and it is worse in one specific way: environment variables turn up in process listings, in crash reports, and in the log line somebody adds while debugging something else. Rotation is what catches people out. An HS256 secret verifies as well as it signs, so the moment the new value is live every token signed with the old one fails, including the ones handed out four minutes ago. Sign with the new secret, verify against both for at least the lifetime of one token, then drop the old one. RS256 makes this easier, because two public keys can sit in JWKS at once and the kid header picks between them.

It verifies the ones it issued. devise-jwt hangs a JWT strategy off Warden, signs on sign-in with the secret in its own initializer, and on each request decodes the token, consults the revocation strategy and sets current_user - so inside a controller that has already been through Devise, the token has been verified. What it does not do is touch a token some other issuer signed. If your API also accepts tokens from an identity provider, that is a separate decode with a separate key, and mapping the sub claim onto a User is code you write. It also does not leave you as stateless as people expect: every revocation strategy it ships with, apart from the null one, reads or writes the database on each request.

Because verification never asks anybody. The point of a signed token is that the API can accept it from the signature and the claims alone, with no round trip, and that same property means there is nowhere to go and say this one is finished. A token stays good until its exp, so with a 24-hour expiry a signed-out user, a deleted account or a demoted role still carries a working credential for the rest of the day. Every way out gives something back. A denylist of jti values turns each request into a lookup, cheap in Redis but no longer stateless. A per-user token version in the payload costs the same lookup and invalidates everything that user holds at once. Short expiries with a refresh token keep the API stateless and move the check to the refresh endpoint, which is usually the right trade. What does not work is assuming the token stops being accepted because the session ended.

Start creating your next app now