Using JWTs

JSON Web Tokens (JWTs) offer a way to authenticate and authorize clients to your services, without requiring anything external to your service to verify the tokens. This is ideal in a serverless environment where your services need to be able to run as autonomously as possible.

A JWT consists of a set of claims and a signature that verifies that these claims were issued by an issuer that you trust. These claims may identify a user, or they may indicate that a user has access to a particular resource, or that a user has a particular role, or is a member of a group, etc.

For example, when a user logs in to a system, they may be given a JWT that contains their username as a claim. When the user makes a request on any of your services, they can present that JWT, and those services can verify that the token is valid, and read the username from the JWT to know that this request was made by that user.

Another common use case is to use claims for authorization. Imagine a social network, one of the services in that network is a friends service. When the user fetches their list of friends from the friends service, it may send them a JWT for each friend in their friends list. For each friend, the corresponding JWT will contain a claim indicating that that user is a friend of the logged in user. When the logged in user wants to send a message to another friend, they can include the JWT of that friend in their request to send a message, and the message service can verify that that JWT has the necessary friendship claim, and allow the message to be sent.

When signing JWTs, there are two general approaches, one is to use symmetric keys, the other is to use asymmetric keys. Symmetric keys require the service that issues the token and the service that verifies the token to have the same key. Because they have the same key, they can both issue the same tokens. Symmetric keys are very simple, and are useful when you trust all the services that are verifying keys.

Asymmetric keys have a private key, and a public key. Services that are issuing tokens need the private key, and only the private key can be used to issue tokens. Services that are verifying tokens only need the public key, and the public key can’t be used to issue tokens. This is useful for when you don’t trust the services that are verifying tokens.

JWT Key Configuration

Before the JWT feature can be used, JWT keys need to be set up for a service. Each service can have a list of keys associated with it. This allows JWTs from multiple different sources and for multiple different destinations that may require their own keys to be validated and signed. Akka Serverless decides on a key to use first by issuer, then by key id. If a JWT has no issuer defined, then all keys are considered capable of signing or validating it. If a JWT has no key id defined, then the first key in the list that matches the issuer and algorithm being used is chosen.

Akka Serverless supports the following algorithms:

Name Description Type

HMD5

HMAC with MD5

Symmetric

HS224

HMAC with SHA224

Symmetric

HS256

HMAC with SHA256

Symmetric

HS384

HMAC with SHA384

Symmetric

HS512

HMAC with SHA512

Symmetric

RS256

RSA with SHA256

Asymmetric

RS384

RSA with SHA384

Asymmetric

RS512

RSA with SHA512

Asymmetric

ES256

ECDSA with SHA256

Asymmetric

ES384

ECDSA with SHA384

Asymmetric

ES512

ECDSA with SHA512

Asymmetric

Ed25519

Ed25519

Asymmetric

Which algorithm is suitable for you to use depends on your particular requirements, but we recommend HS256 for symmetric keys, and ES256 for asymmetric keys.

Listing keys

You can list all the keys in your service by running:

akkasls services jwts list <my-service>

This command does not output the secrets themselves. To see the secrets, you can output as JSON:

akkasls services jwts list <my-service> -o json

Generating a key for a service

If you don’t have a specific key that you want to use, you can let akkasls generate one for you:

akkasls services jwts generate <my-service> \
  --key-id <my-key-id> \
  --algorithm HS256 \
  --issuer <my-issuer> \
  --secret <my-secret-name>

This will do two things, it will:

  • Create a new secret suitable for use with the selected algorithm named according to the --secret argument.

  • Add a JWT key to the service that references that secret.

The --issuer is optional, but recommended. It will ensure that if a JWT specifies an issuer claim (iss), only that key will be used to verify that claim. This prevents spoofing of issuer claims. The --key-id is required, and should be unique to the service.

The --secret argument is optional, if not present, the name of the secret will be the argument passed to --key-id.

Adding a key for a service

If you’ve already created a suitable JWT secret, you can add it to a service. For example, perhaps you generated a secret for service A, and you want service B to reuse the same secret. This can be done using the akkasls service jwts add command:

akkasls services jwts add <my-service> \
  --key-id <my-key-id> \
  --algorithm HS256 \
  --issuer <my-issuer> \
  --secret <my-secret-name>

Managing secrets

To create secrets yourself, you can use the akkasls secrets command. To create a symmetric secret for use with HMAC algorithms:

akkasls secrets create symmetric <my-secret-name> \
  --secret-key-literal "<some-secret-text>"

The secret can also come from a file:

akkasls secrets create symmetric <my-secret-name> \
  --secret-key /path/to/secret.key

To create an asymmetric secret:

akkasls secrets create asymmetric <my-secret-name> \
  --private-key /path/to/private.key \
  --public-key /path/to/public.key

The public and private keys must be PEM encoded keys, either RSA, ECDSA or Ed25519. We recommend PKCS8 encoded private keys (that is, keys with a PEM header of BEGIN PRIVATE KEY) and PKIX encoded public keys (with a header of BEGIN PUBLIC KEY), but we also support PKCS1 (BEGIN RSA PRIVATE/PUBLIC KEY) and SEC.1 (BEGIN EC PRIVATE KEY). For some encodings, akkasls may prompt to convert them to a format that the JWT support will work with.

The public and private keys are optional, however you must specify at least one of them. For example, if you have a service that only needs the key to validate JWTs issued by another service, you may configure a secret for it that just contains the public key so that it is unable to sign JWTs with that key.

If you only have the private key, you can also ask Akka Serverless to extract the public key from the private key, rather than having to do it manually yourself and passing it using the --public-key flag:

akkasls secrets create asymmetric <my-secret-name> \
  --private-key /path/to/private.key \
  --extract-public-key

JWT annotations in protobuf

Akka Serverless’s JWT support is configured by placing annotations on methods and messages in your service’s protobuf descriptor.

Bearer token validation

If you want to validate that a bearer token is present on a request, this can be done by annotating the gRPC method:

rpc MyMethod(MyRequest) returns (MyResponse) {
  option (akkaserverless.method).jwt = {
    validate: BEARER_TOKEN
  };
};

Only requests that have a bearer token that can be validated by one of the configured keys for the service will be allowed, all other requests will be rejected. The bearer token must be supplied with requests using the Authorization header, like:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

If you want to assert that only tokens from a particular issuer are allowed, that be can be done using the bearer_token_issuer option:

rpc MyMethod(MyRequest) returns (MyResponse) {
  option (akkaserverless.method).jwt = {
    validate: BEARER_TOKEN
    bearer_token_issuer: "my-issuer"
  };
};

It is recommended that this be used in combination with specifying an issuer in your JWT key configuration, otherwise any of the services whose keys you trust may spoof the issuer.

Akka Serverless will place the claims from the validated token into the request metadata, so you can access it from your service. All claims are prefixed with _akkasls-jwt-claim-, so for example, if you want to read the subject claim, you can read the metadata header _akkasls-jwt-claim-sub. String claims are passed unquoted. All other claim types, including arrays and objects, are passed using their JSON encoding.

Message validation and signing

Akka Serverless supports both validation and signing of messages. This is done through annotations that indicate which fields in a message contain JWT tokens, and which fields in a message contain claims that should be included in the token when signing, or validated against the claims in the token when validating.

Annotating gRPC methods

In order for Akka Serverless to know which methods should have their incoming messages validated, and which should have their outgoing messages signed, a method level annotation is required. If you want to validate an incoming message:

rpc MyMethod(MyRequest) returns (MyResponse) {
  option (akkaserverless.method).jwt = {
    validate: MESSAGE
  };
};

If you want to sign an outgoing message:

rpc MyMethod(MyRequest) returns (MyResponse) {
  option (akkaserverless.method).jwt = {
    sign: MESSAGE
  };
};

Note that validation and signing of messages and bearer tokens may be mixed, for example:

rpc MyMethod(MyRequest) returns (MyResponse) {
  option (akkaserverless.method).jwt = {
    validate: BEARER_TOKEN
    validate: MESSAGE
    sign: MESSAGE
  };
};

Annotating token fields

A field may be marked as containing a token using the following annotation:

string my_token_field = 1 [(akkaserverless.field).jwt = {
  token: true
}];

The field must either be of type string or bytes, string is recommended. Only one string/bytes annotated token field is allowed per message. When signing, a JWT with claims extracted from the message (according to the claim annotations described later) will be written to this field before being sent to the client. When validating, a JWT will be extracted from this field, its signature will be validated, and then the claims will be validated according to the claim annotations in the message. If no token is present, or if any part of the validation fails, the request will be rejected.

Setting token expiry

By default, all tokens generated by Akka Serverless have an expiry of one hour (3600 seconds). This can be customised using the expires_seconds option:

string my_token_field = 1 [(akkaserverless.field).jwt = {
  token: true
  expires_seconds: 300
}];

Setting the value to -1 instructs Akka Serverless to not set an expiry claim.

Signing and validating nested messages

If you want to sign or validate a nested message, you may do this by annotating it with the token annotation:

MyChildMessage my_child_message = 1 [(akkaserverless.field).jwt = {
  token: true
}];

This indicates that the signing and validation should descend into this message. The message must have at least one token annotated field itself, and may recurse further. Repeated message fields may also be signed and validated:

repeated MyChildMessage my_child_messages = 1
  [(akkaserverless.field).jwt = {
    token: true
  }];

Specifying fields to by signed or validated

A message with a token is not very useful if that token doesn’t assert any claims. Any field of a message can be included as a claim using the claim annotation:

string my_claim_field = 1 [(akkaserverless.field).jwt = {
  claim: INCLUDE
}];

When signing, this will instruct Akka Serverless to extract the value from my_claim_field and set it as a claim in the JWT. The name of the claim will be my_claim_field. Claims can be of almost any type. The only unsupported type is maps that don’t have string keys. Message types will be serialised to JSON objects, repeated fields will be serialised to JSON arrays, bytes fields will be serialised to strings using Base64 encoding. If the value in the field is the zero value for that type, that is, if the field is empty, no claim will be extracted and included in the token.

When validating, Akka Serverless will extract the value from my_claim_field, and ensure that it matches a corresponding claim in the JWT. If the claim doesn’t exist in the JWT, validation will fail. If the claim does exist, but has a different value, validation will fail. If the value of the field is the zero value for its type, no validation will be done, even if that claim exists in the JWT. An important thing to note here is that this means that boolean claims are only validated if they are true. If they are false, no validation is done, since false is the empty value for boolean.

A custom name for the claim may be specified:

string my_claim_field = 1 [(akkaserverless.field).jwt = {
  claim: INCLUDE
  name: "my-claim"
}];

This will be used both during validation and signing.

Including claims from child messages

A claim may be included from a child message by annotating that message with the claim descend annotation:

MyChildMessage my_child_message = 1 [(akkaserverless.field).jwt = {
  claim: DESCEND
}];

This instructs Akka Serverless to descend into that message and extract fields according to the claim annotations in that message, and use them as claims in the JWT for this message. The claims will appear as top level claims in the JWT. Only non repeated messages may have a descend claim annotation on them. Descend claims can recurse multiple messages deep, however, they must not recurse cyclically (where a descended message descends directly or indirectly into itself). If this is detected, an error will be raised when the service is started.

Nesting claims from child messages

If you set a message field to INCLUDE, that entire message will be serialised to a JSON object and included as a claim. If you want to control how that message is serialised, you can instead use the NEST claim annotation:

MyChildMessage my_child_message = 1 [(akkaserverless.field).jwt = {
  claim: NEST
}];

This instructs Akka Serverless to descend into that message and extract fields according to the claim annotations in that message, and include them inside an object as a single claim in this message’s JWT. This differs from DESCEND in that if you extract two fields, foo and bar from the child message, in a NEST claim, they will appear in the JWT claims like this:

{
  "my_child_message": {
    "foo": "value",
    "bar": "value"
  },
  "some_other_claim_field": "value"
}

Whereas when using DESCEND, they will appear in the JWT claims like this, unnested:

{
  "foo": "value",
  "bar": "value",
  "some_other_claim_field": "value"
}

The name of the wrapping claim may be customized using the name option. Nested claims must be messages, and can be repeated. Nested claims may recurse multiple messages deep, however, they must not recurse cyclically (where a nested message directly or indirectly nests or descends into itself). If this is detected, an error will be raised when the service is started.

Including and validating an issuer

You can require that a JWT from a message has a particular issuer, using the issuer option:

string my_token_field = 1 [(akkaserverless.field).jwt = {
  token: true,
  issuer: "my-issuer"
}];

When validating, this will require that the issuer claim (iss) matches the given value. When signing, it will assert the given value as an issuer claim. This will also influence which configured keys are used to validate/sign the token.

Extracting values from claims

When validating, you can instruct Akka Serverless to extract claims from a JWT, and write them into the incoming message, like so:

string my_claim_field = 1 [(akkaserverless.field).jwt = {
  claim: EXTRACT
}];

If the field is present on the incoming message, the incoming value will still be validated, it won’t be overwritten by the extracted value. EXTRACT claims have the same effect as INCLUDE claims when signing.

Including claims from a bearer token

Sometimes it may make sense to include claims from an incoming bearer token when validating or signing a token in a message - for example, if you want to ensure that only the currently authenticated user may use the token. This can be done using the include_bearer_token_claim option on token annotated fields:

string my_token_field = 1 [(akkaserverless.field).jwt = {
  token: true,
  include_bearer_token_claim: "sub"
}];

When signing, this will extract the subject claim from the bearer token, and include it in the message token. When validating, this will extract the subject claim from the bearer token, and validate that it matches the subject claim in the message token. If the bearer token is not present, the claim is not present in the bearer token, or the claim is not present in the message token, validation will fail. Bearer token claims can only be included if the method is annotated with validate: BEARER_TOKEN.

Ad-hoc claims

You may not want to model all your claims in your protobuf message structure. In that case, you can use raw fields:

map<string, string> my_raw_claims = 1 [(akkaserverless.field).jwt = {
  claim: RAW
}];

Raw claims are string keyed maps, with each entry in the map being a claim named according to the key. The value in the map can be anything, and will be serialised to JSON. When signing, the claims will be extracted from the field and included in the token, and the raw claim field will be cleared from the message before sending. When validating, all claims that match the type of the values in the map will be extracted into the map - any values already in the map will be cleared. Multiple RAW annotated fields can be used for extracting and signing raw claims of different types, for example, strings, numbers, messages.

Including claims from parent messages in tokens

Tokens in nested messages may also include claims made by their parent message, by annotating the token in the nested message with include_parent_claims:

string my_token_field = 1 [(akkaserverless.field).jwt = {
  token: true,
  include_parent_claims: true
}];

This may be useful if you want a token for a message to appear in a child message, rather than at the top level, or if you have a set of repeated messages with tokens in them, and there are some claims that you want included in each of the message’s tokens that come from the parent and that you don’t want to duplicate in the child.

Running locally with JWT support

When running locally using docker compose, by default, a dev key with id dev is configured for use. This key uses the JWT none signing algorithm, which means the JWT tokens produced do not contain a cryptographic signature, nor are they validated against a signature when validating. The tokens still contain all the claims, and messages are validated against those claims, so you can still effectively verify that JWT support is working, the lack of signature just means that tokens can trivially be forged, which is not usually an issue when testing locally.

If you wish to set the issuer for this dev key, you can do that modifying the docker-compose.yml file in your project, setting the JWT_DEV_SECRET_ISSUER environment variable in the akka-serverless-proxy service:

version: "3"
services:
  akka-serverless-proxy:
    ...
    environment:
      JWT_DEV_SECRET_ISSUER: "my-issuer"
      ...