Skip to content

Reloading page bypasses TLS client authentication #35317

Description

@maikeriva

What steps will reproduce the bug?

  • Obtain (or create) a CA certificate, a server certificate, and a server key supplied by said CA.
  • Compile (with typescript) and run the following code:
import fs from 'fs';
import path from 'path';
import https from 'https';
import tls from 'tls';
import express from 'express';

/* Alias environment variables */
const port = process.env.PORT || 443;

const httpsOptions = {
    key: fs.readFileSync(path.join('certs', 'server.key')),
    cert: fs.readFileSync(path.join('certs', 'server.crt')),
    ca: fs.readFileSync(path.join('certs', 'ca.crt')),
    requestCert: true,
    rejectUnauthorized: false /* This is necessary to accept self-signed certificates, we will perform the authentication ourselves */
};

/* Create Express app */
const expressApp = express();
const expressServer = https.createServer(httpsOptions, expressApp);

expressApp.use((req,res,next) => {
    console.log((req.socket as tls.TLSSocket).authorized ? 'true' : 'false');
    if (!(req.socket as tls.TLSSocket).authorized) {
        return res.status(401).send('Unauthorized');
    }
    next();
});

expressServer.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});
  • Open Firefox (version 80) and go to https://localhost. You should have your connection rejected (output is 'false').
  • Reload the page. Your connection is now accepted (output is 'true').
  • This curiously doesn't happen on Chromium.

How often does it reproduce? Is there a required condition?

  • Always reproducible. Chromium seems to not trigger the issue.

What is the expected behavior?

  • Connection should always be rejected

What do you see instead?

  • Connection is accepted after page reload

Additional information

Activity

  1. added
    tlsIssues and PRs related to the tls subsystem.
    docIssues and PRs related to Node.js documentation.
    on Sep 24, 2020
  2. bnoordhuis commented on Sep 24, 2020

    @bnoordhuis
    Member

    I'm guessing it might be due to some TLS connection reuse logic.

    Close, it's not the connection but the TLS session that's reused. :-)

    Reuse cuts the handshake short (and cuts out the client certificate exchange) because it reuses the previously established session parameters. That's per spec and normally what you want. Chromium probably creates a new session when you reload.

    You can force a new session by checking socket.isSessionReused() and then calling socket.renegotiate() but caveat emptor, only up to TLS v1.2 - TLS v1.3 doesn't support renegotiation.

    (req.socket as tls.TLSSocket).authorized flag should always be false if the client certificate doesn't match the server CA. In this case it becomes true after a page reload from Firefox for unknown reasons.

    That's an issue on our side (and I can see why it's surprising behavior) but I hesitate to call it an outright bug.

    socket.authorized is false when a verification error happened during the handshake (e.g. invalid or untrusted certificate) but true otherwise.

    A new connection started from a resumed session doesn't do that verification and hence assumes socket.authorized = true. The nature of TLS sessions is such that I'm not sure this can be fixed even if we wanted to.

    I do feel the documentation for socket.authorized should mention that caveat. Currently it only says this:

    Returns true if the peer certificate was signed by one of the CAs specified when creating the tls.TLSSocket instance, otherwise false.

    I've added the doc label. Pull request welcome!

  3. maikeriva commented on Sep 29, 2020

    @maikeriva
    Author

    Thanks for the answer!
    However, I believe that if this is not a code bug it is at least a severe security flaw and should be addressed as such. I found many many examples on the web for client authentication which relied on the concept that socket.authorized == false, and god knows how many websites are relying on that. That might be due to a documentation gap, yet the effect on security is the same.

    Furthermore, thanks for explaining how to verify the certificate through each request. However I think that approach has two flaws:
    1: Renegotiation is forced on each request, and I believe that's expensive for the server.
    2: Such approach won't work on TLS 1.3 (which alternatives are there?)

    I believe that ensuring that keeping socket.authorized == false by default could solve all of these problems in a simple manner.

  4. bnoordhuis commented on Sep 29, 2020

    @bnoordhuis
    Member

    I won't argue you're wrong but keep in mind this only happens because your script turns off authentication with { rejectUnauthorized: false }. The default ensures sessions are properly authenticated before they're established.

    The comment in your script says it's to allow self-signed certificates. Self-signed certificates by themselves don't provide authentication, they're just a pinky promise that the signer is who he says he is.

    I assume you're checking the certificate fingerprint as a way to authenticate the client? That's okay-ish from a security viewpoint but consider moving to a self-signed CA certificate that signs your client certificates. That way you're working with rather than around the system. :-)

    2: Such approach won't work on TLS 1.3 (which alternatives are there?)

    TLSv1.3 has a concept of post-handshake authentication but Node.js doesn't support that yet and might never because it interacts badly with HTTP/2, see RFC 8740.

    I believe that ensuring that keeping socket.authorized == false by default could solve all of these problems in a simple manner.

    You're welcome to open a pull request but be ready for some pushback because that's a backwards incompatible change with a lot of potential for ecosystem fallout.

  5. maikeriva commented on Sep 29, 2020

    @maikeriva
    Author

    I won't argue you're wrong but keep in mind this only happens because your script turns off authentication with { rejectUnauthorized: false }. The default ensures sessions are properly authenticated before they're established.

    The comment in your script says it's to allow self-signed certificates. Self-signed certificates by themselves don't provide authentication, they're just a pinky promise that the signer is who he says he is.

    That's true, I didn't notice that turning rejectUnauthorized: true actually enforces the check at each page reload. I keep it false because we want to use a fallback authentication mechanism alongside client certificates.
    The comment in the code snippet is outdated, actually I am using a self-signed CA which in turn issued server and client certificates.

    I guess you might be right about retrocompatibility of such a pull request (even though developers which stuck to the documentation are going to have the expected behaviour, yet I also expect that the ones who figured out the quirk are possibly going to be surprised).

    I think a documentation update is likely the best approach, yet I would also issue alongside it an official notice to inform developers who didn't figure this out.

  6. bnoordhuis commented on Sep 29, 2020

    @bnoordhuis
    Member

    Do you want to open a documentation pull request?

    I'm not quite sure what you mean by "official notice" - a note in the release notes?

  7. maikeriva commented on Oct 2, 2020

    @maikeriva
    Author

    I'm not quite sure what you mean by "official notice" - a note in the release notes?

    Yep, exactly! Sorry I could not find the correct words.
    I might consider doing a pull request later on on, everyone is free to make its own though without waiting mine :)

  8. luigidt commented on Oct 30, 2020

    @luigidt

    Hey guys, I was going a little crazy because of the same issue...

    I won't argue you're wrong but keep in mind this only happens because your script turns off authentication with { rejectUnauthorized: false }. The default ensures sessions are properly authenticated before they're established.

    Is it possible to implement the same behavior in the handler?
    (I tried to look for the default implementation but just couldn't find it)

  9. eraytufan commented on Jan 20, 2021

    @eraytufan

    I have a similar issue. However in my case socket.authorized turns into a true when I send the same request second time even when client certificate is never authenticated. Surely, I am not the only one and it must be a bug. Otherwise, I don't see the benefit of adding such method and like others have said it is very misleading.

    Edit: I found the issue, I don't know if I still call it a bug. I think the issue depends on both client and server. In my part, if client and server handshake is done at least even once, I could turn off the client, close the connection or even change the client certificate, the client was able to reconnect the server without any problems. I think if the client saves the previously established session in some way then it can always reconnect to the server.

    I think documentation should be updated like others have suggested. Unfortunately, I saw the same issue in a lot of code snippets and it might lead to security issues if the developers do not realize it.

  10. donnd-t commented on May 12, 2021

    @donnd-t

    Would be nice to have a solution for this or at least have it documented. This was unexpected behavior for me.

  11. Tarrowren commented on Jul 13, 2022

    @Tarrowren

    chrome also triggers this issue. You need to wait for about a minute before the second request

  12. github-actions commented on Jun 27, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 210 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  13. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jun 27, 2026
  14. soreavis commented on Jul 18, 2026

    @soreavis
    Contributor

    Re-measured this on current main before writing the doc text: when the client presented a certificate, the verification result — including the error — carries across resumption on both TLS 1.2 and 1.3 (SSL_get_verify_result reads the session-stored result), so authorized doesn't flip to true in that case. The path that still flips is TLS 1.3 with no client certificate at all: full handshake false → resumed true, with getPeerCertificate() empty — the PSK carve-out from #23188. Opened #64584 documenting the caveat as invited, with the practical isSessionReused() + peer-cert check for rejectUnauthorized: false servers.

  15. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jul 19, 2026
  16. pimterry commented on Jul 22, 2026

    @pimterry
    Member

    I know it's been a while, but I do think this is cleanly fixable to work as expected, and we should reconsider the current behaviour. In addition to the docs PR that @soreavis opened documenting the current state, I've opened a PR to change the behaviour itself in future here: #64677

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    docIssues and PRs related to Node.js documentation.tlsIssues and PRs related to the tls subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions