diff --git a/services/tunnelbroker/src/notifs/fcm/error.rs b/services/tunnelbroker/src/notifs/fcm/error.rs
--- a/services/tunnelbroker/src/notifs/fcm/error.rs
+++ b/services/tunnelbroker/src/notifs/fcm/error.rs
@@ -6,6 +6,7 @@
   ReqwestError(reqwest::Error),
   InvalidHeaderValue(reqwest::header::InvalidHeaderValue),
   SerdeJson(serde_json::Error),
+  FCMTokenNotInitialized,
 }
 
 impl From<jsonwebtoken::errors::Error> for Error {
diff --git a/services/tunnelbroker/src/notifs/fcm/mod.rs b/services/tunnelbroker/src/notifs/fcm/mod.rs
--- a/services/tunnelbroker/src/notifs/fcm/mod.rs
+++ b/services/tunnelbroker/src/notifs/fcm/mod.rs
@@ -1,20 +1,31 @@
 use crate::notifs::fcm::config::FCMConfig;
+use crate::notifs::fcm::token::FCMToken;
+use std::time::Duration;
 
 pub mod config;
 mod error;
+mod token;
 
 #[derive(Clone)]
 pub struct FCMClient {
   http_client: reqwest::Client,
   config: FCMConfig,
+  token: FCMToken,
 }
 
 impl FCMClient {
   pub fn new(config: &FCMConfig) -> Result<Self, error::Error> {
     let http_client = reqwest::Client::builder().build()?;
+
+    // Token must be a short-lived token (60 minutes) and in a reasonable
+    // timeframe.
+    let token_ttl = Duration::from_secs(60 * 55);
+    let token = FCMToken::new(&config.clone(), token_ttl)?;
+
     Ok(FCMClient {
       http_client,
       config: config.clone(),
+      token,
     })
   }
 }
diff --git a/services/tunnelbroker/src/notifs/fcm/token.rs b/services/tunnelbroker/src/notifs/fcm/token.rs
new file mode 100644
--- /dev/null
+++ b/services/tunnelbroker/src/notifs/fcm/token.rs
@@ -0,0 +1,68 @@
+use crate::notifs::fcm::config::FCMConfig;
+use crate::notifs::fcm::error::Error;
+use crate::notifs::fcm::error::Error::FCMTokenNotInitialized;
+use jsonwebtoken::{Algorithm, EncodingKey, Header};
+use serde::Deserialize;
+use serde_json::json;
+use std::sync::Arc;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+use tokio::sync::RwLock;
+use tracing::debug;
+
+#[derive(Debug, Clone, Deserialize)]
+struct FCMAccessToken {
+  access_token: String,
+  token_type: String,
+  expiration_time: u64,
+}
+
+#[derive(Debug, Clone)]
+pub struct FCMToken {
+  token: Arc<RwLock<Option<FCMAccessToken>>>,
+  config: FCMConfig,
+  validity_duration: Duration,
+}
+
+impl FCMToken {
+  pub fn new(config: &FCMConfig, token_ttl: Duration) -> Result<Self, Error> {
+    Ok(FCMToken {
+      token: Arc::new(RwLock::new(None)),
+      config: config.clone(),
+      validity_duration: token_ttl,
+    })
+  }
+
+  pub async fn get_auth_bearer(&self) -> Result<String, Error> {
+    let bearer = self.token.read().await;
+    match &*bearer {
+      Some(token) => Ok(format!("{} {}", token.token_type, token.access_token)),
+      None => Err(FCMTokenNotInitialized),
+    }
+  }
+
+  fn get_jwt_token(&self, created_at: u64) -> Result<String, Error> {
+    let exp = created_at + self.validity_duration.as_secs();
+    let payload = json!({
+      // The email address of the service account.
+      "iss": self.config.client_email,
+      // 	A descriptor of the intended target of the assertion.
+      "aud": self.config.token_uri,
+      // The time the assertion was issued.
+      "iat": created_at,
+      // The expiration time of the assertion.
+      // This value has a maximum of 1 hour after the issued time.
+      "exp": exp,
+      // A space-delimited list of the permissions that the application
+      // requests.
+      "scope": "https://www.googleapis.com/auth/firebase.messaging",
+    });
+
+    debug!("Encoding JWT token for FCM, created at: {}", created_at);
+
+    let header = Header::new(Algorithm::RS256);
+    let encoding_key =
+      EncodingKey::from_rsa_pem(self.config.private_key.as_bytes()).unwrap();
+    let token = jsonwebtoken::encode(&header, &payload, &encoding_key)?;
+    Ok(token)
+  }
+}