Configuring step-ca Templates

People use private CAs for all sorts of things, in many different contexts: web apps, mobile apps, code signing, cloud VM instances, SSH, IoT devices, etc. So step-ca must be flexible enough to handle a wide variety of flows.

X.509 and SSH certificate templates open up these possibilities. With certificate templates, you can do things like:

  • Add custom SANs or extensions to X.509 certificates
  • Make longer certificate chains, with multiple intermediate CAs
  • Use SSH force-command or source-address extensions
  • Add conditionals around a certificate's parameters, and fail if they are not met

Certificate templates are JSON documents that describe the most important properties in the final certificate or certificate request. They use Go templates at the core and are extended with Sprig functions.

A few default X.509 and SSH templates are hardcoded into step-ca.

In the next sections, we'll cover X.509 template parameters, SSH template parameters, and how to configure the CA to use templates.


X.509 Templates

X.509 templates can be used with step certificate or step-ca. Here's what the default X.509 leaf certificate template looks like:

{ "subject": {{ toJson .Subject }}, "sans": {{ toJson .SANs }}, {{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }} "keyUsage": ["keyEncipherment", "digitalSignature"], {{- else }} "keyUsage": ["digitalSignature"], {{- end }} "extKeyUsage": ["serverAuth", "clientAuth"] }
X.509 Template Variables

Here are some common variables available in X.509 certificate template:

  • .Subject: This is the subject that was passed in to step certificate or step ca certificate. Specifically, .Subject.CommonName contains the Common Name for the certificate.

  • .SANs: Subject Alternative Names. This is a list of maps containing SANs for the certificate. Unless SANs are specified (using the --san flag, for example), the .Subject.CommonName is the default SAN.

  • .Token: If a signed token was used to obtain the certificate —for example, with the JWK provisioner— this property contains the payload of that token.

  • .AuthorizationCrt: For provisioners that use certificates to authorize requests (eg. the X5C provisioner), this is the certificate used to authorize the current certificate request with the CA. The client has proven posession of this certificate's private key. The .AuthorizationCrt will have the following properties: Nonce, Serial, Type, KeyID, Principals, ValidBefore, ValidAfter, CriticalOptions, Extensions, and Reserved (See the Certificate struct shown here).

  • .AuthorizationChain: For provisioners that use certificates to authorize requests (eg. the X5C provisioner), this is an array of the certificate chain from the request. This chain connects the authorization certificate to the root CA configured in the provisioner.

  • .Insecure.CR*: ☠️ This holds the Certificate Request (CSR) received from the client. How step-ca treats these values depends on the provisioner. While a CSR is always passed to step-ca (often alongside a signed token), in most cases only the public key value in the CSR is used by step-ca. These properties are marked insecure because CSRs are not explicitly authenticated by step-ca. Instead, step-ca will authenticate the signed values from OIDC tokens, JWK tokens, cloud IIDs, and the validated SANs from ACME orders. For ACME, step-ca validates that the CSR's subject and SANs only contain values that are in the validated ACME order.

  • .Insecure.CR.DNSNames: The DNS Name Subject Alternative Names (SANs) provided in the CSR

  • .Insecure.CR.IPAddresses: The IP Address SANs provided in the CSR

  • .Insecure.CR.URIs: The URI SANs provided in the CSR

  • .Insecure.CR.EmailAddresses: The Email Address SANs provided in the CSR

  • .Insecure.CR.Subject.CommonName: The Common Name (CN) provided in the CSR's subject

  • .Insecure.CR.Subject.ExtraNames: The Extra Names provided in the CSR subject. This is an array of all subject distinguished names (DNs) in the CSR. Each contains an OID (type) and a value (value).

  • .Insecure.CR.PublicKey: The public key provided in the certificate request. You can check the request's key type with a conditional, like {{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}. You can check the request's key size with a conditional, like {{- if lt .Insecure.CR.PublicKey.Size 384 }}.

  • .Insecure.User: This property holds user-supplied data from the certificate request. Users can supply arbitrary values using --set or --set-file flags in step ca certificate.

You can also import values from the "templateData" object in your provisioner's configuration block.

SSH Templates

step-ca also supports SSH certificate templates. Here is step-ca's default SSH certificate template:

{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "extensions": {{ toJson .Extensions }}, "criticalOptions": {{ toJson .CriticalOptions }} }

The OpenSSH certificate protocol spec defines the available Critical Options and Extensions. Third parties can also add custom extensions.

SSH Template Variables

Here are the most relevant parameters available in SSH certificate template:

  • .Token: This property offers the best opportunity for injecting signed, secure data into the certificate. If a token was used to obtain the certificate,
    this property contains token payload used in the certificate request. For example, when using an OAuth ID token, will contain the user's email address. To add custom data to .Token, you could use custom OAuth claims, or sign your own JWTs.

  • .Extensions: is a map containing extensions. The default value for Extensions is:

    { "permit-X11-forwarding": "", "permit-agent-forwarding": "", "permit-port-forwarding": "", "permit-pty": "", "permit-user-rc": "" }
  • .CriticalOptions: is a map containing critical options. It is empty by default.

  • .Principals: For users, this value is derived from the OIDC token (when using the OIDC provisioner). For hosts, it is derived from the Instance Identity Document (when using the Cloud provisioners).

  • .KeyID: The key ID being requested.

  • .Type: The type of SSH certificate being requested. This will be user or host.

  • .Insecure.CR*: SSH certificate requests to step-ca are not CSRs in the X.509 sense. So, step-ca creates a virtual certificate request, and that's what this variable represents.

  • .Insecure.CR.Principals: If you trust a host to register its own custom SANs (for example, when using the IID provisioner), then the host can pass several --principal values to step ssh certificate when registering the host, and use .Insecure.CR.Principals to access those from a template.

  • .Insecure.CR.Type: The type of SSH certificate being requested. This will be user or host.

  • .Insecure.CR.KeyID: The key ID being requested.

  • .Insecure.User: This property holds user-supplied data from the certificate request. Users can supply arbitrary values using --set or --set-file flags in step ssh certificate.

You can also import parameter values from your provisioner's configuration block.

Configuring the CA to Use Templates

Within provisioner configuration, certificate templates can be set under the property "options". The following snippet shows a provisioner with custom X.509 and SSH templates:

{ "type": "JWK", "name": "", "key": { "use": "sig", "kty": "EC", "kid": "lq69QCCEwEhZys_wavar9RoqRLdJ58u_OGzJK0zswSU", "crv": "P-256", "alg": "ES256", "x": "pt7T0n98qREZUkyUX6b4kXJ5FkJlIdiMfJaLFclZIng", "y": "Pw1y1xqe4g4YARwyBSkEkcjNrtPYxdKlYDLI512t2_M" }, "encryptedKey": "eyJhbGciOiJQQkVTMi1IUzI1NitBMTI4S1ciLCJjdHkiOiJqd2sranNvbiIsImVuYyI6IkEyNTZHQ00iLCJwMmMiOjEwMDAwMCwicDJzIjoiQ1dWZG5zWTR2bGZJbG9BQ1dOaUdNUSJ9.12dowlkvESpxJgrNJgP2ELDQz750HSh2w6Ux6BmatBE5-ybAJFFO7g.1cjU2-CTrV3gbUE7.m8a95nv4qLnN_K_PG7lzgzYXBGnw_aHCf-znJ34AZxzPy2QDGGEjN_V0jX3kvHH9AIg3cs8I8NRm__RDm2iezU5AhPoAHaqnPnZdKh0ReBZ4hNpYXUHlTPf4fRaCXXDQiKatxNzCMBpyqKpudf3xYUzZPRNMN78WM0ZeIzmv_jFzbryOpxD8bJ3Bnxa4e8Am_pPdAANHphodlKN2nDr4701OLKgitImm8RoA20sWdAI_LkTS_Abk_TqBo_3qOGdjmnRBtATFSu3BdQw5wZMjywfwCWKXUK_OUt-cjVIe11xUT43SoE8fR2GJJEKomAHP0vn0MUUMqY9P9icUejw.eEYI_H7WfrYDL4yhsnsJxg", "claims": { "enableSSHCA": true } "options": { "x509": { "templateFile": "templates/certs/x509/leaf.tpl", "templateData": { "OrganizationalUnit": "Engineering Team" } }, "ssh": { "templateFile": "templates/certs/ssh/host.tpl" } } }
  • options: object that allows configuration of provisioner options e.g. templates.

    • x509 or ssh: object for configuring X.509 or SSH certificate template options.

      • templateFile: path to a template stored on disk. You have a few options for how to define your path:

        • absolute path: e.g. /home/mariano/path/to/file.ptl
        • relative to $(step path): e.g. templates/certs/x509/leaf.tpl the actual location of which would be $(step path)/templates/certs/x509/leaf.tpl.
        • relative to the execution directory of step-ca: e.g. ./path/to/file.tpl or ../path/to/file.tpl
      • templateData: defines variables that can be used in the template. In the example above, you will be able to use the defined organizational unit as the variable {{ .OrganizationalUnit }}, for example in a template like:

        { "subject": { "organizationalUnit": {{ toJson .OrganizationalUnit }}, "commonName": {{ toJson .Subject.CommonName }} }, "sans": {{ toJson .SANs }}, "keyUsage": ["digitalSignature"], "extKeyUsage": ["serverAuth", "clientAuth"] }
      • template: set the full template in a string. The value can be the string representation of a JSON object, or you encoded in base64. For example:

        { "x509": { "template": "{{ toJson .Insecure.CR }}", } }

        Or using base64:

        { "x509": { "template": "e3sgdG9Kc29uIC5JbnNlY3VyZS5DUiB9fQ==", } }

Basic X.509 Template Examples

Leaf Certificates

The most common type of certificate you'll want to create is a leaf (or end-entity) certificate; this is the type of certificate that your servers or clients will use.

The default leaf certificate template is:

{ "subject": {{ toJson .Subject }}, "sans": {{ toJson .SANs }}, {{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }} "keyUsage": ["keyEncipherment", "digitalSignature"], {{- else }} "keyUsage": ["digitalSignature"], {{- end }} "extKeyUsage": ["serverAuth", "clientAuth"] }

We will explain what each block does, but first, let's look at what a rendered template might look like. Imagine that you run the following command to create a certificate that has as an email SAN.

step ca certificate jane.crt

The rendered template (from which the X.509 certificate will be generated and signed) is:

{ "subject": { "commonName": "" }, "sans": [{"type": "email", "value": ""}], "keyUsage": ["digitalSignature"], "extKeyUsage": ["serverAuth", "clientAuth"] }

Going back to the template above, we can see that the template has blocks delimited by {{ and }}. These are called actions - actions are data evaluations or control structures. The ones in the default template are:

  • {{ toJson .Subject }}: renders .Subject as JSON. toJson is a function in Sprig that encodes the passed item into JSON. .Subject is a variable available in all templates that contains the <subject> parameter passed in the CLI, in this case,, and to be more precise, this value is available in .Subject.CommonName.

  • {{ toJson .SANs }}: renders .SANs (Subject Alternative Names) as JSON. The variable .SANs is also available in all templates and contains the list of SANs passed from the CLI. If no SANs are specified, the .Subject.CommonName will be used as a default SAN (e.g. in our example). If you add more SANs using the flag --san, step-ca will auto-assign the correct SAN type (dns, ip, uri, or email).

  • {{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }}: defines a condition based on the key type in the certificate request. typeIs is a Sprig function that returns true if the type of the data in .Insecure.CR.PublicKey matches *rsa.PublicKey, this is the Go specific type for RSA public keys. If the condition is true, the keyUsage is set to keyEncipherment and digitalSignature, otherwise just digitalSignature. keyEncipherment is used in the RSA key exchange protocol, and it must only be set if an RSA key is used. .Insecure.CR is a structure that represents the certificate (signing) request or CSR, from it we can extract SANs, the subject, extensions, etc.

Finally we also set the extended key usage serverAuth and clientAuth so the certificate can be used in a server application or in a client for mTLS.

Intermediate Certificates

An intermediate certificate is a certificate that can be used to sign another certificate, and it has itself been signed by another intermediate certificate or by a root certificate.

The default template for an intermediate certificate is:

{ "subject": {{ toJson .Subject }}, "keyUsage": ["certSign", "crlSign"], "basicConstraints": { "isCA": true, "maxPathLen": 0 } }

This template is most commonly used when initializing a CA.

The keyUsage certSign allows a certificate to sign other certificates. crlSign allows a certificate to sign Certificate Revocation Lists or CRLs.

In basicConstraints we say that this certificate is a CA, and the path length constraint, maxPathLen, is maximum number of non-self-issued intermediate certificates that may follow this certificate in a valid certification path. An intermediate with a maxPathLen set to 0 won't be able to sign another intermediate certificate. To be precise, it can sign it, but the new intermediate won't be trusted by clients, so it won't be useful.

Knowing this we can also guess that the intermediate certificate's issuer had at least a maxPathLen of 1. So if you want to use an intermediate certificate that can sign other intermediates, make sure to set more permissive maxPathLen in the root and intermediate certificate used.

The command step certificate create with the --profile intermediate-ca flag uses the template above, but if you want to create an intermediate with a custom template you can run:

step certificate create --template intermediate.tpl \ --ca ~/.step/certs/root_ca.crt --ca-key ~/.step/secrets/root_ca_key \ "ACME Intermediate CA" intermediate_ca.crt intermediate_ca_key
Root Certificates

A root certificate is a self-signed certificate used to sign other certificates. The default root certificate template is:

{ "subject": {{ toJson .Subject }}, "issuer": {{ toJson .Subject }}, "keyUsage": ["certSign", "crlSign"], "basicConstraints": { "isCA": true, "maxPathLen": 1 } }

Here we are specifying that the issuer is equivalent to the subject, and the maxPathLen is set to 1, so it can sign intermediate certificates with maxPathLen set to 0. If you want to allow intermediates to sign other intermediates, you need to set the maxPathLen to at least 2. It's also possible to not limit the number of intermediates that can follow this certificate if we set maxPathLen to -1, and then create intermediates that set a specific limit, i.e. 0 for those that can sign only leaf certificates and 1 for those that can sign at most one downstream intermediate certificate.

The command step certificate create with the --profile root-ca flag uses the above template. To create a custom root certificate, you can run:

step certificate create --template root.tpl "ACME Root CA" root_ca.crt root_ca_key

Advanced X.509 Template Examples

In the previous section, we learned how to create and use the basic templates, but we've just scratched the surface of what's possible. With templates, you can customize and parameterize every aspect of an X.509 or SSH certificate.

Below, we'll walk through a few advanced templating examples.

Certificate Request

Let's start with one of the shortest templates:

{{ toJson .Insecure.CR }}

This template produces a signed certificate that contains all the information present in the certificate request (with a few caveats). All SANs and extensions present in the certificate request will be in the final certificate.

The only modifications that a signed certificate might have (over a CSR), would be in the certificate subject on step-ca if a template is provided in the ca.json, and it will set the Subject Alternative Name extension to critical if the subject is empty.

User-Provided Variables in Signing Requests

In step-ca, X.509 templates can also be parameterized with variables that will be provided by step at certificate creation time. A common use case for variables is when you receive a CSR from another team, or a CSR embedded in hardware, and you need to define a SAN for it.

For example, below is an X.509 template that accepts the user variable dnsName but falls back to the default leaf template if it's not present:

{ "subject": {{ toJson .Subject }}, {{- if .Insecure.User.dnsName }} "dnsNames": {{ toJson .Insecure.User.dnsName}}, {{- else }} "sans": {{ toJson .SANs }}, {{- end }} {{- if typeIs "*rsa.PublicKey" .Insecure.CR.PublicKey }} "keyUsage": ["keyEncipherment", "digitalSignature"], {{- else }} "keyUsage": ["digitalSignature"], {{- end }} "extKeyUsage": ["serverAuth", "clientAuth"] }

You can then use step to sign a CSR like so:

$ step ca sign --set "" input.csr output.crt # Or $ echo '{"dnsName": ""}' > vars.json $ step ca sign --set-file vars.json input.csr output.crt

Both flags, --set <name=value> and --set-file <path> are available in step ca certificate and step ca sign. If you need to pass more than one variable, you can use --set multiple times or use a JSON file with multiple properties.

It's worth mentioning the while we used "dnsNames" instead of "sans" in the example above, both can be used. "dnsNames" is an array of strings (or just one string if only one is required), while "sans" is an array of objects like:

[ {"type": "dns", "value": ""}, {"type": "email", "value": ""}, {"type": "ip", "value": ""}, {"type": "uri", "value": ""}, ]

The variable .SANs is generated by the provisioners with the values of the trusted names.

Besides "dnsNames", you can also use "emailAddresses", "ipAddresses", and "uris".

Adding Name Constraints

Name constraints are useful in intermediate certificates when you want to limit the validity of certificates that an intermediate can sign. If we want to only allow DNS name like * we can generate an intermediate with the template:

    "subject": {{ toJson .Subject }},
    "keyUsage": ["certSign", "crlSign"],
    "basicConstraints": {
        "isCA": true,
        "maxPathLen": 0
    "nameConstraints": {
        "critical": true,
        "permittedDNSDomains": [""]

Given a root certificate and a key, you can generate the intermediate with:

step certificate create —template intermediate.tpl --ca root_ca.crt --ca-key root_ca_key \ “Intermediate CA” intermediate.crt intermediate.key

Besides "permittedDNSDomains", the "nameConstraints" property accepts all the following properties:

{ "nameConstraints": { "critical": false, "permittedDNSDomains": [""], "excludedDNSDomains": [""], "permittedIPRanges": [""], "excludedIPRanges": [""], "permittedEmailAddresses": [""], "excludedEmailAddresses": [""], "permittedURIDomains": [""], "excludedURIDomains": [""], } }

Remember that in certificate templates, if an array only has one member, you can write it as a string:

{ "nameConstraints": { "critical": true, "permittedDNSDomains": "" } }
Arbitrary X.509 Extensions

SANs, key usages, extended key usages, basic constraints, and name constraints, are all extensions in an X.509 certificate. Templates provide an easy way for assigning extensions without the need of using the actual extension bytes.

The same thing is also possible with top-level certificate template properties like:

  • unknownExtKeyUsage: defines custom extended key usages using ASN.1 object identifiers; this is useful if the templates do not support a string version of the extended key usage (RFC 5280,

  • ocspServer: sets a list of OCSP servers in the Authority Information Access extension (RFC 5280,

  • issuingCertificateURL: defines the issuing certificate URL in the Authority Information Access extension (RFC 5280,

  • crlDistributionPoints: defines URLs with the certificate revocation list (RFC 5280,

  • policyIdentifiers: defines a list of ASN.1 object identifiers with certificate policies (RFC 5280,

But if you need to create your own extension, or use an unsupported one, you can also write a custom extension like:

{ "extensions": [ {"id": "", "critical": false, "value": "Y3VzdG9tIGV4dGVuc2lvbiB2YWx1ZQ=="} ] }

The crux here is that the value of the extension is the base64 encoding of the actual bytes that go into that extension, so if you are encoding a structure in your extension using the ASN.1 encoding, you will have to put the base64 version of the encoded bytes.

X.509 OpenVPN certificates

One interesting use case for certificate templates is the signing of OpenVPN certificates. These certificates require specific key usages not available in the default templates.

This is a template you can use in a provisioner signing OpenVPN client certificates:

  "subject": {"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}},
  "sans": {{ toJson .SANs }},
  "keyUsage": ["digitalSignature", "keyAgreement"],
  "extKeyUsage": ["clientAuth"]

And the following template can be used for a provisioner signing OpenVPN server certificates:

  "subject": {{ toJson .Subject }},
  "sans": {{ toJson .SANs }},
  "keyUsage": ["digitalSignature", "keyEncipherment", "keyAgreement"],
  "extKeyUsage": ["serverAuth"]

A full configuration of step-ca and OpenVPN is available in our blog post "Announcing X.509 Certificate Flexibility"

SSH Template Examples

GitHub SSH Certificates

With GitHub Enterprise Cloud or GitHub Enterprise Server, you can configure GitHub to use SSH certificates. Specifically, GitHub will trust an SSH Certificate Authority for your team. But, to get it to work, you have to generate custom SSH certificates for your users using a extension, where the value of the extension is their GitHub Username. This custom extension value authenticates your users to GitHub Enterprise. Which is great: The same certificates that let you SSH into your servers now also let you push code.

Here's an SSH template that supports the GitHub custom SSH certificate extension:

{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "criticalOptions": {{ toJson .CriticalOptions }}, {{ if .Token.ghu }} "extensions": { "": {{ toJson .Token.ghu }} } {{ else }} "extensions": {{ toJson .Extensions }} {{ end }} }

To use this template, you'd need to add a ghu ("GitHub Username") custom claim to the token that you send the CA to authenticate your certificate request.

If you use an OIDC provisioner with this template, you can configure your identity provider to serve a custom claim labeled ghu in your ID token. Our blog post "Clever Uses of SSH Certificate Templates" has more details on this option.

The JWK and Cloud provisioners also use tokens. Here's an example of a Bash test script that decrypts your JWK provisioner key, and uses it to sign a token that step-ca will accept for an SSH certificate:

#!/bin/bash # # This script is for testing purposes only. # # Your CA hostname and port CA=ca.local:443 # Key ID: This is the "kid" value from your JWK provisioner configuration KID=R0a0VGbbOe3j6ol5jOdV3qfiigjcezk0LG9K9Cp7mrg # Issuer: The "name" of your JWK provisioner ISS="" if [ "$#" -ne 2 ]; then name=$(basename $0) echo "Usage: $name <princiap> <github-username>" exit 1 fi # Grab the key to encrypt KEY=`curl --cacert ~/.step-ssh-templates/certs/root_ca.crt -s https://${CA}/1.0/provisioners/${KID}/encrypted-key | jq -r .key | step crypto jwe decrypt` echo '{"step": {"ssh":{"certType": "user", "keyID": "'$1'"}}, "ghu":"'$2'"}' | step crypto jwt sign --key <(echo $KEY) --iss "$ISS" --aud "https://${CA}/1.0/sign" -sub $1 -exp $(date -v+5M +"%s")

Here's the script used in concert with the GitHub SSH template above:

$ TOKEN=$(./ carl tashian) Please enter the password to decrypt the content encryption key: ... $ echo $TOKEN | step crypto jwt inspect --insecure { "header": { "alg": "ES256", "kid": "R0a0VGbbOe3j6ol5jOdV3qfiigjcezk0LG9K9Cp7mrg", "typ": "JWT" }, "payload": { "aud": "https://localhost:4430/1.0/sign", "exp": 1606870196, "ghu": "tashian", "iat": 1606869896, "iss": "", "jti": "6d62922871db260e54d97fe1f2561440177ae579e31baa954128e0fba155b4db", "nbf": 1606869896, "step": { "ssh": { "certType": "user", "keyID": "carl" } }, "sub": "carl" }, "signature": "RdYo70cCR7tMZBl8VLbxVpoCDlNED2wpRw8uPV1rNkDENBbXqml1h-TnWG23dJ4zKzPhAuO2Vk7HdOUC2q0mNg" } $ step ssh certificate carl github --token $TOKEN ✔ CA: https://localhost:4430 Please enter the password to encrypt the private key: ✔ Private Key: github ✔ Public Key: ✔ Certificate: ✔ SSH Agent: yes $ step ssh inspect Type: user certificate Public key: ECDSA-CERT SHA256:SdcgsNKaQbhYveanJYjmJk4eLN3+CBKUKkRGv4xYn5U Signing CA: ECDSA SHA256:MKwRQ/SDKk/pCJbbCk5bfhZACjSjv7uZXLyc5n4Wx6k Key ID: "carl" Serial: 886026644913671581 Valid: from 2020-12-01T16:40:32 to 2020-12-02T08:41:32 Principals: carl Critical Options: (none) Extensions: tashian

In production, you would not want to hand out your JWK provisioner password. But you could create a token server that interoperates with step-ca and injects secure values into the token.

SSH Group Accounts

Let's say your organization has shared group accounts on your hosts (eg. "dba" or "devops"). You'd want to be able to issue people in those groups a certificate that contains their user principals ("carl", ""), plus principals for any group accounts they need access to.

Let's use the OIDC ID token to inject additional principals into an SSH user certificate.

First, configure a custom groups claim with your identity provider, and add the claim to the payload of the ID token. In this example, we're assuming the groups claim contains a space-separated list of possible group accounts.

Then, use the following template to merge the group accounts with the user's own principals (derived from the email claim):

{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson ((concat .Principals (splitList " " .Token.groups)) | uniq) }}, "criticalOptions": {{ toJson .CriticalOptions }}, "extensions": {{ toJson .Extensions }} }

This is a good example of the use of Sprig functions in templates.

Finally, test out your flow with step ssh login, and use step ssh inspect to check the principals of the certificate you're issued by the CA.

Even More Templates

To delve deeper into certificate templates and all the options, the best source of information is the code docs for the package:

More information and more examples are available in the blog posts "Announcing X.509 Certificate Flexibility" and "Clever Uses of SSH Certificate Templates".