Stealing your Telegram account in 10 seconds flat

Say you handed me your phone, what’s the worst I could do in 10 seconds?
Click that link and your browser will be logged into telegram without passwords

The other day I received an interesting message with a link to Telegram’s web client. Upon clicking on the link, I found myself already logged in. Curious, I logged out, sent myself a message with the same link, clicked, and was logged in once again. There wasn’t anything special about the link I had been sent, this is just Telegram’s default behavior.

I wanted to look into how this works. The first step was to figure out how the Telegram client was passing the session to the browser. As I clicked on the link, I noticed something flash on the URL bar for just a split second:

It seems like Telegram just opens up a URL with your account’s token appended to it. The token gets put in a hash fragment, and quickly disappears once the web client loads up and realizes there’s a token there. Although very convenient, this feature is pretty concerning because it can be used to quickly gain access to your account even if you use 2FA and a locked-down device (eg a non-rooted/jailbroken phone).

So where does this URL and its session come from? I searched tdesktop1’s code for various keywords such as “” and “tgWebAuthToken”, but oddly enough I didn’t get any hits. After staring at the code and not finding anything related to this feature for a while, I decided to build the app for real and attach a debugger to it.

A couple hours of compiling later, I had my very own build of tdesktop up and running. I set up a few breakpoints, clicked on some test links, and stepped through the code looking for the relevant bits. And eventually, I got here:

79 80 [[nodiscard]] bool BotAutoLogin( 81 const QString &url, 82 const QString &domain, 83 QVariant context) { 84 auto &account = Core::App().activeAccount(); 85 const auto &config = account.appConfig(); 86 const auto domains = config.get<std::vector<QString>>( 87 "url_auth_domains", 88 {}); 89 if (!account.sessionExists() 90 || domain.isEmpty() 91 || !ranges::contains(domains, domain)) { 92 return false; 93 } 94 const auto good = url.startsWith(kBadPrefix, Qt::CaseInsensitive) 95 ? (kGoodPrefix + url.mid(kBadPrefix.size())) 96 : url; 97 UrlAuthBox::Activate(&account.session(), good, context); 98 return true; 99 } 100
Name Value Type
Main::AppConfig::get<std::vector<QString,std::allocator<QString> > > returned { size=5 } std::vector<QString,std::allocator<QString>> &
account {_domain={ptr_=0x000001b3822c5990 {_dataName={...} _local={...} _accounts={...} ...} } _local=unique_ptr {_owner={ptr_=0x000001b3887a6dd0 {_domain={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={ptr_=0x000001b3822c5990 {_dataName=data _local=unique_ptr {_owner={...} _dataName=data _localKey=shared_ptr [2 strong refs] [] ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} ...} } ...} } ...} ...} Main::Account &
config {_account={ptr_=0x000001b3887a6dd0 {_domain={...} _local={...} _mtp={...} ...} } _api={_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr {_instance={ptr_=0x000001b3886a1b40 {_private=unique_ptr } } _mode=Normal (0) _config=unique_ptr {_dcOptions={...} _fields={...} _updates={...} } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} } } ...} ...} const Main::AppConfig &
context {...} QVariant
domain const QString &
domains { size=5 } std::vector<QString,std::allocator<QString>>
[capacity] 5 unsigned __int64
[allocator] allocator std::_Compressed_pair<std::allocator<QString>,std::_Vector_val<std::_Simple_types<QString>>,1>
[0] QString
[1] QString
[2] QString
[3] QString
[4] QString
[Raw View] {_Mypair=allocator } std::vector<QString,std::allocator<QString>>
good ??? QString
url const QString &

So that’s why I couldn’t find the keywords earlier! The list of domains this trick works with is sent to you by the Telegram server and stored in the config under the url_auth_domains2 key. You can see the list of domains currently provided in the locals above.

Once you click on a link with a matching domain, your client will send it to Telegram’s servers and if everything looks alright you’ll get back a cute little temporary URL with the tokens and everything appended. For those playing along at home, we send a messages_requestUrlAuth with just the url set3, and hope to get back a urlAuthResultAccepted with the new url inside.

Having figured out how the thing works, and armed with a list of domains, I began looking for a way to break it. It seems like the entire initial URL gets preserved, including the path, query parameters, and the hash fragment, with the exception of the scheme being forced to https.

For example:

(a lot) more examples
Original URL URL with token🐴🐴#tgWebAuthToken=trixie&tgWebAuthUserId=starlight&tgWebAuthDcId=sunset🐴🐴#tgWebAuthToken=trixie&tgWebAuthUserId=starlight&tgWebAuthDcId=sunset🐴🐴#tgWebAuthToken=...&tgWebAuthUserId=420493337&tgWebAuthDcId=4🐴#tgWebAuthToken=trixie&tgWebAuthUserId=starlight&tgWebAuthDcId=sunset🐴#tgWebAuthToken=trixie&tgWebAuthUserId=starlight&tgWebAuthDcId=sunset&tgWebAuthToken=...&tgWebAuthUserId=420493337&tgWebAuthDcId=4

All of the domains apart from the one are sort-of built for the deep links. Going on any of them without a path will just bring you to the homepage. Going on one with a compatible path, such as, will open up the respective client with a hash fragment, eg:​tgaddr=​tg%3A%2F%2Fmsg_url%3F​

This is usually performed with a HTTP 301 redirect, but if the tgWebAuth parameter is set and the deep link is valid, you’ll get to run this fun javascript instead:

<meta name="robots" content="noindex, nofollow">
<noscript><meta http-equiv="refresh" content="0;url=''"></noscript>
try {
var url = "https:\/\/\/a\/#?";
var hash = location.hash.toString();
if (hash.substr(0, 1) == '#') {
hash = hash.substr(1);
location.replace(hash ? urlAppendHashParams(url, hash) : url);
} catch (e) { location.href=url; }

function urlAppendHashParams(url, addHash) {
var ind = url.indexOf('#');
if (ind < 0) {
return url + '#' + addHash;
var curHash = url.substr(ind + 1);
if (curHash.indexOf('=') >= 0 || curHash.indexOf('?') >= 0) {
return url + '&' + addHash;
if (curHash.length > 0) {
return url + '?' + addHash;
return url + addHash;
<!-- page generated in 4.3ms -->

I was a bit puzzled at first, but eventually realized that this is all just a simple hack to deal with URL hash fragments. The hash fragment part of the URL never gets sent to the server, so the server cannot know where to redirect you if it also wants to add its own hash fragment. In this specific case, we have #tgWebAuthToken=... in the URL already, and we want to combine it with #?tgaddr=... as we redirect to the web client (so in the end we get #?tgaddr=...&tgWeb​AuthToken=...).

For the rest of the night I played around with Telegram’s various web clients. A little-known fact is that the legacy Telegram web client can still be accessed to this day by going to What’s more, the session is shared between the web clients, so an exploit in the old web client might still be useful even if the target uses a modern web client.

I couldn’t find anything too interesting in WebK, but both WebZ and the legacy client provided some promising leads in messing with the tgaddr in the URL. It ended up being a dead end for my research though, as I couldn’t figure out a way to get rid of or bypass the ampersand in the &tgWebAuthToken=... part of the URL. I even messed around with unicode to see if that’d somehow let me “erase” the ampersand, but it seems like UTF-8 has been designed to withstand this.

I then looked into the mobile apps. Both the iOS and Android clients support the link authentication thing, which makes the whole situation a tad more worrying considering it’s generally a lot harder to just copy a session token off a mobile device. On Android I messed around with intents, but ended up at another dead end as intents for web links have been locked down since Android 12 and require domain verification to work. I also messed around with protocol intents, but the way the app has been written prevents the token from being appended in those cases.

So, no exploit?

In my research I was unable to come up with a successful remote exploit. I won’t be getting a bug bounty, but that doesn’t mean it was all in vain. Combining all the research so far, and adding a little cherry on top, we can create a scenario where we can steal someone’s Telegram session in just a few seconds of physical access to their device, no matter if it’s their computer, phone, or tablet.

We start off by sending “” in their Telegram app and tapping on the link. This will redirect their browser to​#tgWebAuthToken=.... From here we edit the domain in the browser to - a domain I own - and hit/tap enter. The javascript on my domain will take it from here, logging one of my own devices in with the token.

Here’s a demo of me pulling off the entire attack in less than 10 seconds on an Android phone and a laptop:

Note: YouTube has removed my video for a community guidelines violation. Educational content like this is allowed, but I believe their review team confused my video with a hacking tutorial (probably due to livesplit splits looking too much like a step-by-step instruction). They’ve also refused my appeal. You can watch a plain mp4, a Vimeo upload, or this twitter post instead.

This attack is incredibly easy to pull off even for a low-skill attacker. Assuming some higher forces have already set up a custom domain for you, all you need to know is how to tap on a link and add a letter onto the URL bar. You don’t need any specialized tools, you don’t need to know anything about the target, you don’t even need a phone.

So what should Telegram do about this?

Log in to Telegram by QR Code

  1. Open Telegram on your phone
  2. Go to Settings > Devices > Link Desktop Device
  3. Point your phone at this screen to confirm login

The same thing they did with the QR code logins! If you attempt to log onto a new device by scanning a login QR code, you’ll still have to enter your 2FA password - and I think the same mitigation could be implemented for these instant login web client URLs.

Discuss this post on: twitter, mastodon, hackernews, cohost

thank you for reading my first blog post!!

i decided to take on the challenge of using no images on the page, and no javascript either. i don’t think this’ll be a sustainable way of doing things going forward, but it does mean you get fast load times (everything here is ~20kB gzipped!), pretty vector graphics on hidpi screens (try zooming in!), and responsive “screenshots” for mobile devices (try resizing the window and see how neatly things change to accommodate!). also if you’re someone using assistive technologies, please let me know how this post felt to read and if there’s anything to be improved or done differently in the future.

note: the graphics in this blog post are not fully compatible with netscape navigator, please switch to a modern alternative such as ladybird

  1. tdesktop is the official cross-platform desktop client (Telegram Lite on macOS) ↩︎

  2. url_auth_domains is a list of domains used for logging into the web clients, but there is another list under the autologin_domains key, which is used for webapps such as↩︎

  3. There are also peer, msg_id, and button_id fields, but if we set our flag to f_url (4) we skip them. ↩︎