diff --git a/docs/guides/jwt.md b/docs/guides/jwt.md
new file mode 100644
index 0000000000..5240303db6
--- /dev/null
+++ b/docs/guides/jwt.md
@@ -0,0 +1,69 @@
+# JWT usage
+
+## Generating JWT
+
+Use `signJwt` to generate a JWT signed by an account provided in arguments.
+```ts
+import { MemoryAccount, signJwt } from '@aeternity/aepp-sdk';
+
+const account = MemoryAccount.generate();
+const payload = { test: 'data' };
+const jwt = await signJwt(payload, account);
+```
+
+Provide `sub_jwk: undefined` in payload to omit signer public key added by default.
+Do it to make JWT shorter.
+```ts
+const jwt = await signJwt({ test: 'data', sub_jwk: undefined }, account);
+```
+
+Or if you using a different way to encode a signer address.
+```ts
+const payload = {
+ test: 'data',
+ sub_jwk: undefined,
+ address: 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E',
+}
+const jwt = await signJwt(payload, account);
+```
+
+## Verifying JWT
+
+Let's assume we got a JWT as string. Firstly we need to ensure that it has the right format.
+```ts
+import { isJwt, ensureJwt } from '@aeternity/aepp-sdk';
+
+if (!isJwt(jwt)) throw new Error('Invalid JWT');
+// alternatively,
+ensureJwt(jwt);
+```
+
+After that we can pass JWT to other SDK's methods, for example to get JWT payload and signer address
+in case JWT has the signer public key included in `"sub_jwk"`.
+```ts
+import { unpackJwt } from '@aeternity/aepp-sdk';
+
+const { payload, signer } = unpackJwt(jwt);
+console.log(payload); // { test: 'data', sub_jwk: { ... } }
+console.log(signer); // 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E'
+```
+`unpackJwt` will also check the JWT signature in this case.
+
+Alternatively, if `"sub_jwk"` is not included then we can provide signer address to `unpackJwt`.
+```ts
+const knownSigner = 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E';
+const { payload, signer } = unpackJwt(jwt, knownSigner);
+console.log(payload); // { test: 'data' }
+console.log(signer); // 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E'
+```
+
+If we need to a get signer address based on JWT payload then we need to unpack it without checking
+the signature. Don't forget to check signature after that using `verifyJwt`.
+```ts
+import { verifyJwt } from '@aeternity/aepp-sdk';
+
+const { payload, signer } = unpackJwt(jwt);
+console.log(payload); // { address: 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E' }
+console.log(signer); // undefined
+if (!verifyJwt(jwt, payload.address)) throw new Error('JWT signature is invalid');
+```
diff --git a/examples/browser/aepp/src/App.vue b/examples/browser/aepp/src/App.vue
index 8bb57f0151..402ffeeeb7 100644
--- a/examples/browser/aepp/src/App.vue
+++ b/examples/browser/aepp/src/App.vue
@@ -39,6 +39,13 @@
>
Delegation signature
+
+ JWT
+ ({ view: '' }),
};
diff --git a/examples/browser/aepp/src/Jwt.vue b/examples/browser/aepp/src/Jwt.vue
new file mode 100644
index 0000000000..4266462b6f
--- /dev/null
+++ b/examples/browser/aepp/src/Jwt.vue
@@ -0,0 +1,89 @@
+
+
Generate a JWT
+
+
+
Payload as JSON
+
+
+
+
+
+
Include "sub_jwk"
+
+
+
+
+
+
+
Signed JWT
+
+
+
+
+
Unpack and verify JWT
+
+
+
JWT to unpack
+
+
+
+
+
+
Signer address
+
+
+
+
+
+
+
Unpack result
+
+
+
+
+
+
diff --git a/mkdocs.yml b/mkdocs.yml
index 2d02f3f0f0..488d98863e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -59,6 +59,7 @@ nav:
- guides/error-handling.md
- guides/low-vs-high-usage.md
- guides/typed-data.md
+ - guides/jwt.md
- Wallet interaction:
- guides/connect-aepp-to-wallet.md
- guides/build-wallet.md
diff --git a/src/index-browser.ts b/src/index-browser.ts
index edbae28ddf..af54c5c211 100644
--- a/src/index-browser.ts
+++ b/src/index-browser.ts
@@ -10,6 +10,10 @@ export {
generateKeyPairFromSecret, generateKeyPair, sign, verify, messageToHash, signMessage,
verifyMessage, isValidKeypair,
} from './utils/crypto';
+export {
+ signJwt, unpackJwt, verifyJwt, isJwt, ensureJwt,
+} from './utils/jwt';
+export type { Jwt } from './utils/jwt';
export { recover, dump } from './utils/keystore';
export type { Keystore } from './utils/keystore';
export { toBytes } from './utils/bytes';
diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts
new file mode 100644
index 0000000000..5b34e3e41f
--- /dev/null
+++ b/src/utils/jwt.ts
@@ -0,0 +1,126 @@
+import canonicalize from 'canonicalize';
+import AccountBase from '../account/Base';
+import {
+ Encoded, Encoding, decode, encode,
+} from './encoder';
+import { verify } from './crypto';
+import { ArgumentError, InvalidSignatureError } from './errors';
+
+// TODO: use Buffer.from(data, 'base64url') after solving https://github.com/feross/buffer/issues/309
+const toBase64Url = (data: Buffer | Uint8Array | string): string => Buffer
+ .from(data)
+ .toString('base64')
+ .replaceAll('/', '_')
+ .replaceAll('+', '-')
+ .replace(/=+$/, '');
+
+const fromBase64Url = (data: string): Buffer => Buffer
+ .from(data.replaceAll('_', '/').replaceAll('-', '+'), 'base64');
+
+const objectToBase64Url = (data: any): string => toBase64Url(canonicalize(data) ?? '');
+
+const header = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9'; // objectToBase64Url({ alg: 'EdDSA', typ: 'JWT' })
+
+/**
+ * JWT including specific header
+ * @category JWT
+ */
+export type Jwt = `${typeof header}.${string}.${string}`;
+
+/**
+ * Generate a signed JWT
+ * Provide `"sub_jwk": undefined` in payload to omit signer public key added by default.
+ * @param originalPayload - Payload to sign
+ * @param account - Account to sign by
+ * @category JWT
+ */
+export async function signJwt(originalPayload: any, account: AccountBase): Promise {
+ const payload = { ...originalPayload };
+ if (!('sub_jwk' in payload)) {
+ payload.sub_jwk = {
+ kty: 'OKP',
+ crv: 'Ed25519',
+ x: toBase64Url(decode(account.address)),
+ };
+ }
+ if (payload.sub_jwk === undefined) delete payload.sub_jwk;
+ const body = `${header}.${objectToBase64Url(payload)}` as const;
+ const signature = await account.sign(body);
+ return `${body}.${toBase64Url(signature)}`;
+}
+
+/**
+ * Unpack JWT. It will check signature if address or "sub_jwk" provided.
+ * @param jwt - JWT to unpack
+ * @param address - Address to check signature
+ * @category JWT
+ */
+export function unpackJwt(jwt: Jwt, address?: Encoded.AccountAddress): {
+ /**
+ * JWT payload as object
+ */
+ payload: any;
+ /**
+ * Undefined returned in case signature is not checked
+ */
+ signer: Encoded.AccountAddress | undefined;
+} {
+ const components = jwt.split('.');
+ if (components.length !== 3) throw new ArgumentError('JWT components count', 3, components.length);
+ const [h, payloadEncoded, signature] = components;
+ if (h !== header) throw new ArgumentError('JWT header', header, h);
+ const payload = JSON.parse(fromBase64Url(payloadEncoded).toString());
+ const jwk = payload.sub_jwk ?? {};
+ const signer = jwk.x == null || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519'
+ ? address
+ : encode(fromBase64Url(jwk.x), Encoding.AccountAddress);
+ if (address != null && signer !== address) {
+ throw new ArgumentError('address', `${signer} ("sub_jwk")`, address);
+ }
+ if (
+ signer != null
+ && !verify(Buffer.from(`${h}.${payloadEncoded}`), fromBase64Url(signature), signer)
+ ) {
+ throw new InvalidSignatureError(`JWT is not signed by ${signer}`);
+ }
+ return { payload, signer };
+}
+
+/**
+ * Check is string a JWT or not. Use to validate the user input.
+ * @param maybeJwt - A string to check
+ * @returns True if argument is a JWT
+ * @category JWT
+ */
+export function isJwt(maybeJwt: string): maybeJwt is Jwt {
+ try {
+ unpackJwt(maybeJwt as Jwt);
+ return true;
+ } catch (error) {
+ return false;
+ }
+}
+
+/**
+ * Throws an error if argument is not JWT. Use to ensure that a value is JWT.
+ * @param maybeJwt - A string to check
+ * @category JWT
+ */
+export function ensureJwt(maybeJwt: string): asserts maybeJwt is Jwt {
+ unpackJwt(maybeJwt as Jwt);
+}
+
+/**
+ * Check is JWT signed by address from arguments or "sub_jwk"
+ * @param jwt - JWT to check
+ * @param address - Address to check signature
+ * @category JWT
+ */
+export function verifyJwt(jwt: Jwt, address?: Encoded.AccountAddress): boolean {
+ try {
+ const { signer } = unpackJwt(jwt, address);
+ return signer != null;
+ } catch (error) {
+ return false;
+ }
+}
diff --git a/test/unit/jwt.ts b/test/unit/jwt.ts
new file mode 100644
index 0000000000..337303800a
--- /dev/null
+++ b/test/unit/jwt.ts
@@ -0,0 +1,103 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import {
+ signJwt, unpackJwt, verifyJwt, isJwt, ensureJwt,
+ MemoryAccount, ArgumentError, InvalidSignatureError,
+} from '../../src';
+
+describe('JWT', () => {
+ const account = new MemoryAccount('9ebd7beda0c79af72a42ece3821a56eff16359b6df376cf049aee995565f022f840c974b97164776454ba119d84edc4d6058a8dec92b6edc578ab2d30b4c4200');
+ const payload = { test: 'data' };
+ const jwt = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWJfandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiaEF5WFM1Y1dSM1pGUzZFWjJFN2NUV0JZcU43SksyN2NWNHF5MHd0TVFnQSJ9LCJ0ZXN0IjoiZGF0YSJ9.u9El4b2O2LRhvTTW3g46vk1hx0xXWPkJEaEeEy-rLzLr2yuQlNc7qIdcr_z06BgHx5jyYv2CpUL3hqLpc0RzBA';
+ const jwtWithAddress = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWtfMjFBMjdVVlZ0M2hEa0JFNUo3cmhocW5INVlOYjRZMWRxbzRQblN5YnJIODVwbldvN0UifQ._munmgMvg9SE6jJaTYd6tBSV7EtqO_YRV4TkZjQfop6W18hm_fAPWNbwNupS8doaOs2corl4Uc26zUq1Jyl6Bg';
+ const jwtShortest = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.e30.4L-jN-e5p23BHfOYX04CzLBSovALIiM4OEghM6xQAgGbl1g-ANXxFa-DZ3igIo21wemZ7gtlyD_fS-2Y9BWFDQ';
+
+ describe('isJwt, ensureJwt', () => {
+ it('works if correct jwt', () => {
+ expect(isJwt(jwt)).to.be.equal(true);
+ ensureJwt(jwt);
+ });
+
+ it('fails if wrong jwt', () => {
+ const j = 'test';
+ expect(isJwt(j)).to.be.equal(false);
+ expect(() => ensureJwt(j)).to.throw(ArgumentError, 'JWT components count should be 3, got 1 instead');
+ });
+ });
+
+ describe('signJwt', () => {
+ it('signs', async () => {
+ expect(await signJwt(payload, account)).to.be.equal(jwt);
+ });
+
+ it('signs with address', async () => {
+ expect(await signJwt({ address: account.address, sub_jwk: undefined }, account))
+ .to.be.equal(jwtWithAddress);
+ });
+
+ it('signs shortest', async () => {
+ expect(await signJwt({ sub_jwk: undefined }, account)).to.be.equal(jwtShortest);
+ });
+ });
+
+ describe('unpackJwt', () => {
+ it('unpacks', async () => {
+ expect(unpackJwt(jwt)).to.be.eql({
+ payload: {
+ sub_jwk: {
+ crv: 'Ed25519',
+ kty: 'OKP',
+ x: 'hAyXS5cWR3ZFS6EZ2E7cTWBYqN7JK27cV4qy0wtMQgA',
+ },
+ test: 'data',
+ },
+ signer: account.address,
+ });
+ });
+
+ it('fails if address not the same as in "sub_jwk"', () => {
+ const { address } = MemoryAccount.generate();
+ expect(() => unpackJwt(jwt, address))
+ .to.throw(ArgumentError, `address should be ${account.address} ("sub_jwk"), got ${address} instead`);
+ });
+
+ it('unpacks with address', async () => {
+ expect(unpackJwt(jwtWithAddress)).to.be.eql({
+ payload: { address: account.address },
+ signer: undefined,
+ });
+ expect(unpackJwt(jwtWithAddress, account.address)).to.be.eql({
+ payload: { address: account.address },
+ signer: account.address,
+ });
+ });
+
+ it('unpacks shortest', async () => {
+ expect(unpackJwt(jwtShortest)).to.be.eql({ payload: {}, signer: undefined });
+ expect(unpackJwt(jwtShortest, account.address))
+ .to.be.eql({ payload: {}, signer: account.address });
+ });
+
+ it('fails if wrong signature', () => {
+ const { address } = MemoryAccount.generate();
+ expect(() => unpackJwt(jwtShortest, address))
+ .to.throw(InvalidSignatureError, `JWT is not signed by ${address}`);
+ });
+ });
+
+ describe('verifyJwt', () => {
+ it('verifies', () => {
+ expect(verifyJwt(jwt)).to.be.equal(true);
+ expect(verifyJwt(jwt, account.address)).to.be.equal(true);
+ expect(verifyJwt(jwtShortest, account.address)).to.be.equal(true);
+ });
+
+ it('returns false if address not the same as in "sub_jwk"', () => {
+ expect(verifyJwt(jwt, MemoryAccount.generate().address)).to.be.equal(false);
+ });
+
+ it('returns false if address not provided', () => {
+ expect(verifyJwt(jwtShortest)).to.be.equal(false);
+ });
+ });
+});