> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How SSH authentication works on Upsun

> Decode the SSH certificate the Upsun CLI provisions for you, then follow it through the SSH proxy: CA trust, revocation, access documents, and MFA.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-07-28T09:00:00.000Z" }} />

You run `upsun ssh`, and a second later you have a shell inside your production container. You never uploaded a public key. The container has no `authorized_keys` file with your name in it. What let you in?

The answer is an SSH certificate. A few years ago we wrote about [how Platform.sh provisions these certificates](/posts/how-it-works/how-do-you-manage-ssh-keys-in-your-organization): the CLI generates a key pair, sends the public key to a service called Certifier, and gets back a certificate that your SSH client presents automatically. That article covered how the certificate is issued. This one covers the other side: what's inside the certificate, and what the platform does with it when you connect.

## Read your own certificate

The certificate sits in your CLI session directory, and `ssh-keygen` decodes it:

```bash theme={null}
  ssh-keygen -Lf ~/.upsun-cli/.session/sess-cli-default/ssh/id_ed25519-cert.pub
```

```text theme={null}
  Type: ssh-ed25519-cert-v01@openssh.com user certificate
  Public key: ED25519-CERT SHA256:8gtQqi3ELr/wNfavLc5I/7MUgJM54MsPGaJs8EyGaz0
  Signing CA: ECDSA SHA256:DA8PPrXsISAIChUTRieBTFbQ8ZkC3UwRlULKLKvuUsg
  Key ID: "24e0bd07-b176-466f-8ffb-1503d0e79b03"
  Serial: 0
  Valid: from 2026-07-27T12:04:18 to 2026-07-27T12:19:21
  Principals: (none)
  Critical Options: (none)
  Extensions:
          access-id@platform.sh UNKNOWN OPTION (len 47)
          access@platform.sh UNKNOWN OPTION (len 158)
          has-mfa@platform.sh
          permit-X11-forwarding
          permit-agent-forwarding
          permit-port-forwarding
          permit-pty
          permit-user-rc
          token-claims@platform.sh UNKNOWN OPTION (len 180)
          token-id@platform.sh UNKNOWN OPTION (len 30)
```

Two things stand out before you even look at the extensions. The `Key ID` is your user ID, the same UUID the API knows you by. And the validity window is about 15 minutes. This certificate expires before your coffee gets cold, and the CLI transparently requests a new one when it does. That short lifetime is the first layer of security: a stolen certificate is a paperweight within minutes.

## The extensions carry your permissions

The `permit-*` entries are standard OpenSSH extensions. The `@platform.sh` ones are ours, and they turn the certificate from an identity document into an authorization document.

One of them is a flag: `has-mfa@platform.sh` says your session was authenticated with multi-factor authentication. Its presence alone is the signal; it carries no value.

The rest are data. `access@platform.sh` embeds a permission map, a JSON document describing what your session can touch, resource by resource:

```json theme={null}
  {
    "organizations": {
      "01h9zbmrrpe6vjy5c1zk9y4dxq": ["view", "members:list"]
    },
    "projects": {
      "abcdefgh1234567": ["admin"],
      "org=01h9zbmrrpe6vjy5c1zk9y4dxq": ["view"]
    }
  }
```

We call this an access document. The keys are selectors: a bare value matches a resource ID, and `org=` matches every resource in an organization. Read this one out loud and it's a user profile: member of one organization, admin on one project, view access on every other project the organization owns. Selectors can even carry auth requirements, so `abcdefgh1234567&amr=mfa` grants its permissions only to MFA-verified sessions.

`access-id@platform.sh` is an identifier for the same document server-side, which matters later. `token-id@platform.sh` names the OAuth 2 token your session is built on. And `token-claims@platform.sh` embeds claims from that token:

```json theme={null}
  {
    "auth_time": 1785142522,
    "amr": ["sso", "mfa", "sso:google"],
    "grant": "refresh_token",
    "scp": ["offline_access"]
  }
```

If you've worked with OpenID Connect, `amr` and `auth_time` look familiar: how you authenticated, and when. The certificate is, in effect, a signed snapshot of your auth session that any SSH server can verify offline.

## What happens when you connect

You never reach your container directly. Every SSH connection lands on a proxy at the edge of the region, and that proxy is where the certificate gets taken apart.

The first check is trust. The proxy keeps a registry of certificate authority fingerprints, refreshed on a schedule from the same [authority endpoint](https://ssh.api.platform.sh/ssh/authority) your SSH client can query. If your certificate wasn't signed by one of those CAs, it's treated as a plain SSH key, and [uploaded SSH keys](/docs/development/ssh/ssh-keys) follow a separate, more restricted path.

Next come the critical options. Your certificate above has none, but certificates can carry options that pin them to specific regions or hosts. A certificate scoped to `eu-5` is rejected everywhere else, no matter how valid its signature is.

Then the proxy asks a question the certificate can't answer on its own: is the token behind it still alive? The `token-id@platform.sh` extension gets checked against a revocation list. Log out of the CLI, or have your session revoked by an organization owner, and every certificate minted from that token dies with it, even inside its 15-minute window. The check fails closed: if the revocation service can't be reached, the connection is denied rather than waved through on stale claims.

## The access check

With trust established, the proxy decides whether you can reach this specific environment. It prefers a fresh copy of your access document, fetched by the `access-id@platform.sh` identifier, and falls back to the copy embedded in the certificate. Either way, the document gets evaluated against the route you're connecting to: the project, the environment type, and the permission SSH requires there.

If the token was revoked, the embedded claims aren't trusted at all. The proxy queries the auth service in real time with your user ID, and strips the authentication methods from the result, which forces a fresh login for anything that requires one.

One more gate remains: MFA. Organizations can [require MFA](/docs/administration/security/mfa) for SSH access to their projects. The proxy compares that requirement against the `has-mfa@platform.sh` flag. When the flag is missing and the route demands it, you get a banner instead of a shell:

```text theme={null}
  Error: Access denied
  Service: abcdefgh1234567-main-bvxea6i--app
  User: 24e0bd07-b176-466f-8ffb-1503d0e79b03
  Parameters: {"amr":["mfa"]}
  Detail: Additional authentication is required:
           - Multi-factor authentication (MFA)
```

The `Parameters` line isn't decoration. The CLI parses it, walks you through the additional authentication, and retries.

## The last hop

Your connection still has one more leg: from the proxy to the container. Your certificate doesn't travel there, and neither does your key. Instead, the proxy signs a new certificate with its own upstream key, valid for 2 minutes, carrying your user ID, your client IP, and your access document trimmed down to what this route needs. That's how the container knows who you are without ever seeing your credentials.

The trimming matters. Connect to `abcdefgh1234567` with the document above, and the container learns one thing: you're an admin on this project. What else you can see in your organization is none of its business, so it never arrives.

## The takeaway

Every `upsun ssh` runs through this pipeline: a 15-minute certificate proves who you are, a CA registry proves who vouched for you, a revocation check proves your session still exists, an access document proves you belong on this route, and an MFA flag proves how strongly you authenticated. The claims are signed into the certificate, which is what lets the proxy trust them without re-deriving your identity on every connection. The round trips that remain, the revocation check and the fresh access document, exist for the one thing a signed snapshot can't tell you: what changed after it was signed.

That's the trade `authorized_keys` never offered. A key file says one thing: this key may connect, forever, until someone remembers to delete the line. A certificate says who you are, what you may touch, how you logged in, and when the claim stops being true. Revoking access is one API call, not a fleet-wide file edit.

The same trade shows up in audits. SOC 2 and PCI DSS reviews ask the questions this pipeline answers mechanically: who can reach production, how they authenticated, and how fast access dies when someone leaves. On Upsun those answers hold on every connection, because the platform enforces them rather than a policy document: SSH credentials expire in minutes, revocation is central, and MFA is checked at the door. Certificate-based SSH is one control among the [security and compliance](/docs/security) measures you inherit by deploying on the platform, and the certifications behind them are in the [Trust Center](https://upsun.com/trust-center/).

And none of it needed a proprietary protocol. This is how a modern platform does SSH in a regulated environment: a public key infrastructure, with a certificate authority that signs short-lived identities and every hop validating them on its own.

That shape is what makes it safe to run at scale. Trust is central, but enforcement is decentralized: nothing has to be pushed to the machines you connect to, so there is no fleet of `authorized_keys` files to keep in sync and no container holding a credential that outlives your session. And each hop stays isolated, because it only ever receives the claims it needs. A container that gets compromised learns your role on that one project, not the shape of your organization.

Stock OpenSSH certificates, extended through the fields the format reserves for custom data, decoded by the same `ssh-keygen` you've used for years. If you're building something similar, the format leaves you the same room it left us.
