Service to Service Auth
This documentation is written for the new backend system which is the default since Backstage version 1.24. If you are still on the old backend system, you may want to read its own article instead, and consider migrating!
This article describes how service-to-service auth works in Backstage, both between Backstage backend plugins and for external callers who want to make requests to them. This is in contrast to user and user-to-service auth which use different flows.
Each section describes one distinct type of auth flow.
Standard Plugin-to-Plugin Auth
Backstage plugins that use the new backend system and handle credentials using
the auth
and httpAuth
service APIs are secure by default, without requiring
any configuration. They generate self-signed tokens automatically for making
requests to other Backstage backend plugins, and the receivers use the caller's
public key set endpoint to be able to perform verification.
A backend plugin wishing to make a request to another backend plugin acquires
the required token as follows, where auth
and httpAuth
are assumed to be
injected from coreServices.auth
and coreServices.httpAuth
, respectively:
const credentials = await httpAuth.credentials(req);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: '<plugin-id>', // e.g. 'catalog'
});
In this example we are assuming that we are in an Express request handler, and
we extract the caller credentials (typically a user or a service) out of its
req
and make the upstream request on-behalf-of that principal. Prefer to use
this pattern wherever there's an incoming set of credentials to refer to.
If you want to initiate a request entirely as your own service, not on behalf of anybody else, you can do so as follows:
const { token } = await auth.getPluginRequestToken({
onBehalfOf: await auth.getOwnServiceCredentials(),
targetPluginId: '<plugin-id>', // e.g. 'catalog'
});
Callers pass along the tokens verbatim with requests in the Authorization
header:
Authorization: Bearer eyJhbG...
You may occasionally also see some code, e.g. clients to other systems, that
accept a credentials
argument directly instead of a token. For those, just
pass in the credentials as acquired above, instead of making a token. The client
code will know what to do with those credentials internally.
This flow has only one configuration option to set in your app-config:
backend.auth.dangerouslyDisableDefaultAuthPolicy
, which can be set to true
if you for some reason need to completely disable both the issuing and
verification of tokens between backend plugins. This makes your backends
insecure and callable by anyone without auth, so only use this as a last resort
and when your deployment is behind a secure ingress like a VPN.
External callers cannot leverage this flow; it's only used internally by backend plugins calling other backend plugins.
Static Keys for Plugin-to-Plugin Auth
In some special circumstances, such as when running worker nodes on readonly database replicas, you may wish to opt out of the standard database based public-key scheme. As an alternative, you can put static keys in your config that are used for token signing and validation.
You can make keys using the openssl
command line utility.
-
First generate a private key using the ES256 algorithm:
openssl ecparam -name prime256v1 -genkey -out private.ec.key
-
Convert it to PKCS#8 format:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key
-
Extract the public key:
openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key
After this you have the files private.key
and public.key
. Put them in a
place where you know their absolute paths, and then set up your app-config
accordingly:
backend:
auth:
# This is the new section for configuring plugin-to-plugin key storage
pluginKeyStore:
type: static
static:
keys:
- publicKeyFile: /absolute/path/to/public.key
privateKeyFile: /absolute/path/to/private.key
keyId: some-custom-id
As long as all your nodes have this same config with the same set of keys, they will now be able to successfully communicate with each other without touching the database.
You'll note that the keys
value is an array, which is useful for key rotation.
The first entry will always be used for signing, but any of the subsequent
entries will also be used for token validation. This lets you have a period of
time where tokens signed by the previous top entry are still accepted by
receivers, by just inserting your new key pair as the top entry and leaving the
old ones intact. You can remove old private keys however; those won't be used.
Static Tokens
This access method consists of random static tokens that can be handed out to external callers who want to make requests to Backstage backend plugins. This is useful for the most basic callers such as command line scripts, web hooks and similar.
You configure this access method by adding one or more entries of type static
to the backend.auth.externalAccess
app-config key:
backend:
auth:
externalAccess:
- type: static
options:
token: ${CICD_TOKEN}
subject: cicd-system-completion-events
# Restrictions are optional; see below
accessRestrictions:
- plugin: events
- type: static
options:
token: ${ADMIN_CURL_TOKEN}
subject: admin-curl-access
The tokens can be any string without whitespace, but for security reasons should be sufficiently long so as not to be easy to guess by brute force. You can for example generate them on the command line:
node -p 'require("crypto").randomBytes(24).toString("base64")'
The subjects must be strings without whitespace. They are used for identifying each caller, and become part of the credentials object that request recipient plugins get.
Callers must pass along tokens verbatim with requests in the Authorization
header when calling Backstage plugins:
Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
JWKS Token Auth
This access method allows for external caller token authentication using configured JSON Web Key Sets (JWKS). This is useful for callers that are authenticating to our instance of Backstage with third-party tools, such as Auth0.
You can configure this access method by adding one or more entries of type jwks
to the backend.auth.externalAccess
app-config key:
backend:
auth:
externalAccess:
- type: jwks
options:
url: https://example.com/.well-known/jwks.json
issuer: https://example.com
algorithm: RS256
audience: example, other-example
subjectPrefix: custom-prefix
- type: jwks
options:
url: https://another-example.com/.well-known/jwks.json
issuer: https://example.com
The URL should point at an unauthenticated endpoint that returns the JWKS.
issuer
specifies the issuer(s) of the JWT that the authenticating app will accept.
Passed JWTs must have an iss
claim which matches one of the specified issuers.
algorithm
specifies the algorithm(s) that are used to verify the JWT. The passed JWTs
must have been signed using one of the listed algorithms.
audience
specifies the intended audience(s) of the JWT. The passed JWTs must have an "aud"
claim that matches one of the audiences specified, or have no audience specified.
For additional details regarding the JWKS configuration, please consult your authentication provider's documentation.
The subject returned from the token verification will become part of the
credentials object that the request recipient plugins get. All subjects will have the prefix
external:
, but you can also provide a custom subjectPrefix which will get appended before the
subject returned from your JWKS service (ex. external:custom-prefix:sub
).
Callers must pass along tokens with requests in the Authorization
header when
calling Backstage plugins:
Authorization: Bearer eyJhbG...
Legacy Tokens
Plugins and backends that are not on the new backend system use a legacy token flow, where shared static secrets in your app-config are used for signing and verification. If you are on the new backend system and are not using legacy plugins using the compatibility wrapper, you can skip this section.
Configuration (legacy)
In local development, there is no need to configure anything for this auth method. But in production, you must configure at least one legacy type external access method:
backend:
auth:
externalAccess:
- type: legacy
options:
secret: my-secret-key-catalog
subject: legacy-catalog
- type: legacy
options:
secret: my-secret-key-scaffolder
subject: legacy-scaffolder
The old style keys config is also supported as an alternative, but please consider using the new style above instead:
backend:
auth:
keys:
- secret: my-secret-key-catalog
- secret: my-secret-key-scaffolder
The secrets must be any base64-encoded random data, but for security reasons should be sufficiently long so as not to be easy to guess by brute force. You can for example generate them on the command line:
node -p 'require("crypto").randomBytes(24).toString("base64")'
The subjects must be strings without whitespace. They are used for identifying each caller, and become part of the credentials object that request recipient plugins get.
In both of the examples we showed two secrets being specified, but the minimum is one. The order is significant: the first one is always used for signing of outgoing requests to other backend plugins, while all of the keys are used for verification. This is useful if you want to be able to have unique keys per deployment if you are using split deployments of Backstage. Then each deployment lists its own signing secret at the top, and only adds the secrets for those other deployments that it wants to permit to call it.
For most organizations, we recommend leaving it at just one key and migrating to the new backend system as soon as possible instead of experimenting with multiple legacy secrets.
External Callers (legacy)
For legacy Backstage backend plugins, the above configuration is enough. But external callers who wish to make requests using this flow must generate tokens according to the following rules.
The token must be a JWT with a HS256
signature, using the raw base64 decoded
value of the configured key as the secret. It must also have the following
payload:
sub
: the exact string "backstage-server"exp
: one hour from the time it was generated, in epoch seconds
The JWT must encode the alg
header as a protected header, such as with
setProtectedHeader.
The caller then passes along the JWT token with requests in the Authorization
header:
Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
Access Restrictions
Each externalAccess
entry may optionally have an accessRestrictions
key,
which limits what that particular access method can do. Let's look at an
example:
backend:
auth:
externalAccess:
- type: static
options:
token: ${CICD_TOKEN}
subject: cicd-system-completion-events
accessRestrictions:
- plugin: events
In this short example there's only one entry. It says that for anyone trying to
make access with the CICD token, they will be rejected if they try to contact
anything but the events
backend plugin. You could add additional entries to
the array that allow targeting more plugins if that's what you want.
If no accessRestrictions
are added, the access method has unlimited access to
all functionality of all plugins. It is recommended that you try to specify
access restrictions whenever possible, to reduce risk.
Each entry has one or more of the following fields:
-
plugin
: Required. A plugin ID as a string, for example'catalog'
. Permits access to make requests to this plugin. Can be further refined by setting additional fields as per below.Example:
accessRestrictions:
# access to any other plugin will be rejected
- plugin: my-plugin -
permission
: Optional. A collection (comma/space separated string or string array) of permission names. If given, this method is limited to only performing actions with these named permissions in the plugin with the ID given above.Note that this only applies where permissions checks are enabled in the first place. Endpoints that are not protected by the permissions system at all, are not affected by this setting.
Example:
accessRestrictions:
- plugin: my-plugin
# Any other permission check will be rejected.
permission:
- my-plugin.add-item
- my-plugin.remove-item
# Also supports the shorthand form:
# permission: my-plugin.add-item, my-plugin.remove-item -
permissionAttribute
: Optional. A key-value object of permission attributes where each value is a collection (comma/space separated string or string array) of allowed such values. If given, this method is limited to only performing actions whose permissions have these attributes.Note that this only applies where permissions checks are enabled in the first place. Endpoints that are not protected by the permissions system at all, are not affected by this setting.
In practice, this is typically used to limit by the
action
attribute, for'create'
,'read'
,'update'
, or'delete'
values.Example:
accessRestrictions:
- plugin: my-plugin
permissionAttribute:
# Updates and deletes will be rejected.
action:
- create
- read
# Also supports the shorthand form:
# action: create, read
Adding custom or logic for validation and issuing of tokens
The pluginTokenHandlerDecoratorServiceRef
can be used to decorate the existing token handler without having to re-implement the entire AuthService
implementation.
This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation.
The PluginTokenHandler
interface has two methods:
-
issueToken
: This method is used to issue a token for a plugin. It takes in thepluginId
andtargetPluginId
as arguments, and an optionallimitedUserToken
object which can be used to issue a token on behalf of another user. The method returns a promise that resolves to an object containing the issued token. -
verifyToken
: This method is used to verify a token. It takes in the token as an argument and returns a promise that resolves to an object containing the subject of the token and an optional limited user token.
import {
PluginTokenHandler,
pluginTokenHandlerDecoratorServiceRef,
} from '@backstage/backend-defaults/auth';
import { createServiceFactory } from '@backstage/backend-plugin-api';
const decoratedPluginTokenHandler = createServiceFactory({
service: pluginTokenHandlerDecoratorServiceRef,
deps: {},
async factory() {
return (defaultImplementation: PluginTokenHandler) =>
new CustomTokenHandler(defaultImplementation);
},
});